backend/packages/harness/deerflow/agents/lead_agent/agent.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/safety_finish_reason_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/token_usage_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/todo_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/deferred_tool_filter_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/sandbox_audit_middleware.py @7e7f041 backend/packages/harness/deerflow/guardrails/middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @7e7f041 backend/packages/harness/deerflow/sandbox/middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py @7e7f041 backend/packages/harness/deerflow/agents/memory/queue.py @7e7f041 上一站,请求经过 before_agent、before_model、wrap_model_call 三层,模型拿到整理好的 request 并返回 AIMessage。这一站接着走另一半:模型输出如何经过裁决、工具准入和 run 收尾。
请求向内走的半程是”准备”——把状态、消息、工具 schema 都铺好;模型返回后的半程是”裁决和收尾”——结果是否可信?要不要打断?工具调用能不能执行?run 结束时要清理哪些资源?
上一站提到过一条规则:调用前按注册顺序执行,返回后按逆序执行。这一站看的是后半句:模型返回 AIMessage 后,注册越靠后的 middleware 越早处理它,也越早有机会改写结果。
flowchart TD MODEL["模型被调用 · 返回 AIMessage"] --> AM["after_model · 逆序处理 AIMessage"] AM -->|"带 tool_calls:还要用工具"| WTC["wrap_tool_call · 逐个包住每次工具执行"] WTC --> TOOL["执行工具 · 产出 ToolMessage"] TOOL --> FEED["ToolMessage 加回消息历史"] FEED -.->|"回灌给模型,开启下一轮调用"| MODEL AM -->|"无 tool_calls:已是最终答案"| AA["after_agent · run 收尾,结束"]
after_model:逆序处理 AIMessage
after_model 在模型每次返回后执行。这里的”第一层”指实际执行顺序,不是注册顺序。
阅读之前请记住一个概念区别:
一次 run = 一个「模型 ↔ 工具」的大循环,可能转好几圈
一轮 = 模型节点被调用一次(before_model → model → after_model 合起来算一轮)
关键是:after_model 是每一轮的尾,不是整个 run 的尾。 如果这一轮的 AIMessage 带 tool_calls,工具跑完会把结果回灌给模型、开启下一轮;只有当某一轮模型不再带 tool_calls、直接给出最终答案,才轮到 after_agent 给整个 run 收尾。所以 after_model 这层每一轮都跑,after_agent 整个 run 只跑一次。
回到上一站那张 lead 专属清单,其中实现了 after_model 的几个middleware,从上到下的注册顺序是:
Todo → TokenUsage → Title → SubagentLimit → LoopDetection → SafetyFinishReason
逆过来,才是它们的实际执行顺序:
SafetyFinishReason 先执行
LoopDetection
SubagentLimit
Title
TokenUsage
Todo 后执行
SafetyFinishReasonMiddleware 在 agent.py::build_middlewares 里被故意注册得很靠后,注释也说明了原因:它要在逆序派发里最先执行,赶在循环检测、子 agent 计数之前,把不可信的 tool_calls 清掉。下面按这个真实顺序走一遍。
SafetyFinishReason 来自 safety_finish_reason_middleware.py::SafetyFinishReasonMiddleware 。它不是安全分类器,不判断内容安不安全;它消费的是 provider 已经给出的停止信号:
OpenAI 兼容: finish_reason = content_filter
Anthropic: stop_reason = refusal
Gemini: finish_reason = SAFETY / BLOCKLIST / PROHIBITED_CONTENT / ...
字段名和取值各家都不一样,由一个 SafetyTerminationDetector 适配层统一识别,归一成一个内部的 SafetyTermination(记录是哪个 detector,命中在消息的哪个字段、字段的值是什么)——这样下游逻辑就不用关心具体是哪家 provider 了。
当模型因为安全被截断、却又带着半截 tool_calls 时,问题就来了:LangGraph 只要看到 AIMessage 带非空 tool_calls,就会把它路由进工具节点。而被截断的 tool_call 可能只有半个参数——比如 write_file 的 content 写到一半。如果继续执行,就是拿残缺参数写文件。
所以 SafetyFinishReason 在 after_model 里只做一个核心动作:一旦发现”安全截断 + 非空 tool_calls”,就清空 tool_calls,补一段对用户可见的说明,并同时落下三处可观测信号——在这条 AIMessage 的 additional_kwargs 里写一条 safety_termination 元数据、向前端发一个实时 SSE 事件好让 UI 把状态对齐、再写一条持久的 middleware:safety_termination 审计记录备查。它和上一站的 LLMErrorHandling 正好补成一对:
LLMErrorHandling: 没拿到正常 AIMessage(provider 抛了异常)
SafetyFinishReason: 拿到了正常 AIMessage,但它的 tool_calls 不可信
LoopDetection 来自 loop_detection_middleware.py::LoopDetectionMiddleware 。它先要判定什么算”循环”,这有两层:
第一层 · 同一个动作反复做
把每次 tool_call 归一成一个哈希(工具名 + 关键参数)
read_file 会把相近的行号区间归进同一个桶,避免错开几行就算两次不同调用
write_file、str_replace 这类改内容的工具反过来用完整参数,避免把不同改动误判成重复
一批 tool_call 在算哈希前先排序,所以调用顺序变了、哈希照样不变
同一个哈希出现够多次 → 命中
第二层 · 同一类动作做太多次
按工具名计数,比如 read_file 被连调几十次
哪怕每次读的是不同文件,也算一种重复探索 → 命中
天生高频的工具可以按工具名单独调高或调低阈值,避免误伤
命中之后分两挡处理,轻重不同。一个 middleware,两个 hook,各管一半:soft warning 在 wrap_model_call,上一站已经讲过——它得等工具结果回齐,才能把提醒安全地插在 ToolMessage 之后,不破坏配对协议;这一站只看另一半,它在 after_model 这层的 hard stop。
hard stop 的动作是:一旦重复程度达到 hard limit,就直接改写当前 AIMessage——清掉结构化的 tool_calls、连 additional_kwargs 里的原始 tool-call 负载也一并抹掉(上一站 DanglingToolCall 提过,tool-call 信息可能同时躺在这两个地方,只清一处不够)、追加一段 [FORCED STOP] 文本、把 finish_reason 从 tool_calls 改成 stop。改完,这条消息就变成普通助手文本,图自然不会再路由进工具节点。
SubagentLimit 来自 subagent_limit_middleware.py::SubagentLimitMiddleware 。它只看最新那条 AIMessage(也就是模型刚生成的那条):数一数里面有几个 task 工具调用,超过 max_concurrent 就把多出来的截掉,用同一个 message id 替换回去(同 id → langGraph reducer 原地替换,而不是追加一条)。只动最新这条、不碰历史,是因为历史里的 task 调用可能早就有对应的 ToolMessage 结果了,回头改它会破坏配对协议。它放在 after_model,此时模型生成了 tool_calls,工具还没有执行。
为什么不靠 prompt 让模型自己少开?因为资源上限不能只靠模型听话。这是 enforcement,不是建议。
Title 和 TokenUsage 是这一层的”旁路”,Title( title_middleware.py::TitleMiddleware )在第一轮完整对话之后生成标题——只读消息、写 title 字段,失败就退回一个本地标题,不拖累主 run。TokenUsage( token_usage_middleware.py::TokenUsageMiddleware )把模型这一步的 token 用量,以及从 tool_calls 归类出的动作(todo_start、subagent、search 等),用于计费和分析,标到 AIMessage 的 additional_kwargs 上。两者都用 model_copy(复制出一条新消息、只改要改的字段、保留原 id)写回。之所以复制而不就地改原消息,是因为这条消息可能正被 checkpoint、流式输出、后续 middleware 共享引用,就地改会影响它们;复制出一条新的、留着原 id,再靠”同 id → LangGraph reducer 原地替换”顶替原消息,而不是新增一条。
TokenUsage 还多做一件事:把子 agent 的 token 用量并回父消息。task 工具派出子 agent 时,按 tool_call_id 把这个子 agent 的用量缓存起来;等 TokenUsage 在 after_model 看到配对的 ToolMessage,就用同一个 id 把缓存 pop 出来、合并进当初那条派发的 AIMessage——pop 是取完即删,避免同一笔被重复计两次。
Todo 在 after_model 这层负责”防过早收尾”:如果模型在 todo 还没做完时就给出不带工具调用的最终回答,Todo 会排一条 completion reminder,并返回 {"jump_to": "model"} 把控制流跳回模型;下一次 wrap_model_call 再把这条隐藏提醒注入 request。这条提醒故意不写进 graph state——它只是给下一次模型调用的控制提示,不该出现在用户可见的对话里。(Todo 的另外两个 hook 在上一站已经讲过,这里只补上它在向外半程的这一面。)
wrap_tool_call:工具边界
如果 after_model 之后这一轮还要调用工具(AIMessage 带 tool_calls),控制流就进入工具节点。每一次工具执行都会经过 wrap_tool_call 包裹——和 wrap_model_call 一样是调用链包装,只是这次包的是工具,不是模型。
handler 语义也一样:handler(request) 表示继续向内、最终执行工具。在 handler 之前,middleware 可以拦下不让执行(直接返回一个 ToolMessage);在 handler 之后,可以加工工具的返回结果。
从外到内,这一层 middleware 的顺序是:
ToolOutputBudget (最外)
Sandbox
Guardrail?
SandboxAudit
ToolErrorHandling
DeferredToolFilter
Clarification (最内)
分成两类看:一类在 handler 之前决定”这个工具到底让不让执行”,一类在 handler 之后处理结果。
Clarification 来自 clarification_middleware.py::ClarificationMiddleware 。ask_clarification 的行为由 middleware 实现。模型调用它时,wrap_tool_call 拦下,构造一条 ToolMessage,然后返回:
Command(update={"messages": [tool_message]}, goto=END)
也就是把一次”提问”变成 run 级别的暂停:图直接走到 END,等用户回答。前端靠 message.name == "ask_clarification" 把它渲染成澄清卡片,而不是普通工具结果。
这里有个容易误解的边界:wrap_tool_call 一次只看到一个 tool_call。如果模型在同一条 AIMessage 里同时发了 [普通工具, ask_clarification],Clarification 返回的 goto=END 并不能保证取消掉其他工具——在当前 LangGraph 版本里,同一条消息的多个工具调用会被一起执行(async 用 asyncio.gather)。“先澄清、且只澄清”是 prompt 要求ask_clarification工具尽量单个出现,不是 runtime 保证。
那这一轮工具跑完,到底是停在 END、还是绕回模型?结果由 create_agent 在”tools → model”边上的规则决定:只有当这一轮执行到的客户端工具全是 return_direct 时,才走 END。ask_clarification 正好是 return_direct=True,所以只发它一个时,整段 run 会停下等用户;可一旦混进一个普通工具,“全是 return_direct”就不成立,图会带着两个工具的结果绕回模型,澄清也就没有拦住后续。还有一层更隐蔽的约束:ask_clarification 的问题和选项,是在同一轮工具执行之前就生成好的——所以它问不出任何依赖同轮普通工具返回内容的问题,那些结果最早也要等绕回模型的下一轮才用得上。
DeferredToolFilter 的 tool 侧 来自 deferred_tool_filter_middleware.py::DeferredToolFilterMiddleware 。上一站讲过它的 model 侧:从 request.tools 里隐藏未 promoted 的 schema。但藏 schema 不够——模型可能从历史、prompt 泄漏或猜测里调到一个隐藏工具。所以它在 wrap_tool_call 这侧补上 enforcement:真调到隐藏工具,直接返回 ToolMessage(status="error"),不执行。两侧合起来才是 fail-closed,model 侧管”看得见”,tool 侧管”跑得了”。(这条已经在上一站抽象成 ToolVisibilityPolicy)
SandboxAudit 来自 sandbox_audit_middleware.py::SandboxAuditMiddleware 。它只管名字叫 bash 的工具调用:读出 args["command"],先做输入校验,再用正则 / 分词按风险分级,然后三选一:
pass: 其余命令 —— 照常执行
warn: 命中中危模式(pip install、apt install、chmod 777、sudo/su、PATH=…)
—— 照常执行,但在结果里附一条警告
block: 空命令 / 超长 / 含 null 字节,或命中高危模式
(rm -rf /、dd if=、mkfs、curl | bash、/dev/tcp、fork bomb…)
—— 不调 handler,直接返回 error,命令根本不执行
关键安全性质就一句:block 在 handler 之前返回。因为执行动作藏在 handler 链里,被 block 的命令永远到不了那一步。它不是完整沙箱,是一道 bash 命令策略闸;每条 bash 不论结果如何,都会留一条带 时间戳 / thread_id / 命令 / 判定(pass / warn / block)的审计日志。
Guardrail(可选)来自 middleware.py::GuardrailMiddleware ,配置了 provider 才装。它把工具调用(名字、参数、agent 身份)交给一个 GuardrailProvider 要 allow / deny:deny 就不调 handler、返回 error。它和静态的 allowed_tools 分工不同——allowed_tools 决定”模型原则上能看到 / 用哪些工具”,Guardrail 决定”此刻这一次、带着这些参数的调用,允不允许”。典型企业用法:允许 read_file 但拒绝 path=/secrets/*,允许 send_email 但拒绝外部收件人。当前实现还只是个预留扩展点,价值要等接上企业策略 provider。
再看 handler 之后收拾结果的几层。
ToolErrorHandling 来自 tool_error_handling_middleware.py::ToolErrorHandlingMiddleware 。它包在工具执行外面,把工具抛出的普通异常转成协议合法的结果:
try:
result = handler(request)
except GraphBubbleUp:
raise # 控制流信号,原样抛出
except Exception as exc:
return ToolMessage(status="error", ...)
为什么非要补一条 status="error" 的 ToolMessage,而不是让异常直接抛上去?两个理由。一是协议:provider 要求每个 AIMessage(tool_calls) 都得有配对的 ToolMessage。二是 resume 卫生——如果 checkpoint 里留着一个没有结果的 tool_call,恢复时根本分不清它是没执行、执行失败、还是结果丢了;补一条明确的 error 结果,状态才自洽,下一轮模型也能据此重试。
GraphBubbleUp 那一支很关键:它是 LangGraph 的中断 / 暂停 / 恢复信号,不是工具失败。如果把它也吞成 error,就会把控制流伪装成业务失败。它还会把 task(子 agent 入口工具)的结果标准化:给 ToolMessage 贴上结构化的 subagent_status(completed / failed / timed_out),让前端不用去解析 “Task Succeeded” 这种字符串。
Sandbox 的 wrap_tool_call 侧 来自 middleware.py::SandboxMiddleware 。上一站说过 sandbox 默认 lazy:before_agent 通常不 acquire,acquire 发生在第一次 sandbox 工具调用时。问题是,工具里直接改 runtime.state["sandbox"] 只是这次调用的局部修改,不会自动回写图状态。Sandbox 的 wrap_tool_call 就负责发现”这次调用里冒出了新的 sandbox_id”,把它包进 Command 一起提交回 graph state:
Command(update={"sandbox": {"sandbox_id": sandbox_id}, "messages": [tool_message]})
这样 checkpoint / resume 才看得见这个 sandbox 身份。
ToolOutputBudget 的 tool 侧 来自 tool_output_budget_middleware.py::ToolOutputBudgetMiddleware 。上一站讲的是它的 model 侧(把历史里的超大 ToolMessage 换成预览)。在 wrap_tool_call 这侧,它做的是源头治理。先说清它的职责:管理 ToolMessage.content 的大小,发现把大段日志、HTML、grep 结果直接塞进 ToolMessage.content 的情况。一旦发现,它就把完整内容外置成文件,消息里只留 head/tail 预览和路径。
外置到底写到哪儿,取决于这次有没有 sandbox、以及 sandbox 是否把 thread 目录挂了进去:
没有 sandbox → 写宿主机的 outputs 目录
有 sandbox,且挂了 thread 目录 → 也写宿主机 outputs(sandbox 通过同一虚拟路径就能看到)
有 sandbox,但没挂 thread 目录 → 用 sandbox.write_file 直接写进 sandbox 里
(它看的是 provider 上报的能力标记,而不是去猜 provider 名字。)注意它连 Command.update 里的 ToolMessage 也一并处理,避免大输出从 Command 路径绕过预算。
after_agent:run 收尾
当这一轮不再有 tool_calls(模型给了最终回答),图就走向结束。after_agent 在整个 run 收尾时跑一次,做两件主要的事。
Memory 来自 memory_middleware.py::MemoryMiddleware 。它把这轮对话(用户输入、最终回答、纠正 / 强化信号)塞进一个全局的 queue.py::MemoryUpdateQueue ,不直接写任何 ThreadState 字段。队列是debounce的,即去抖,不是普通 FIFO:同一个 (thread, user, agent) 在去抖窗口内只保留最新快照,并把”纠正 / 强化”这类 true/false 标记合并起来。这样一个会话短时间内连跑好几轮,也只触发一次 memory 更新。它内部有两把锁分工:一把守住全局单例队列的创建,一把守住单个队列入队 / 定时器 / 处理状态;memory 更新、模型调用、存储写入都放在锁外做,不阻塞 run 收尾。
Sandbox release 也在 after_agent:sandbox 会调 provider.release(sandbox_id)。但 “release” 不等于”销毁”——LocalSandboxProvider 的 release 是 no-op(本地 per-thread sandbox 可复用),别的 provider 可能关客户端、把容器还进池、或真销毁。生命周期语义在 provider,不在这层 middleware,具体留到后面 sandbox 那一站再展开。
此外,Todo 和 LoopDetection 也在 after_agent 做点清理:清掉本 run 遗留的 reminder / warning 簿记。它们是长生命周期的 middleware 实例,得自己清理干净跨 run 的残留,和上一站开场 before_agent 的清理正好对称。这里的”对称”是错位的——两头清的不是同一份数据:
after_agent 删的是自己这个 run 没用掉的提醒。这里的”提醒”指前面讲过的循环检测 soft warning——它发现模型在重复同类动作,就排一句”你在重复,请收尾”进队列,打算等下一次模型被调用时塞进 request。但万一这一轮模型直接给了最终答案、run 当场结束,那句提醒就再也等不到”下一次模型调用”,此时就不该排在队列里了;after_agent 因此把它删掉。before_agent 删的则是别的 run 遗留的——因为 run 可能崩溃、被取消,after_agent 未必跑得到,新 run 开场前先把共享簿记里的陈旧条目清掉。一个清自己、一个兜底他人,所以两次都需要,不是同一件事做两遍。
向外分层的好处
- 安全、循环、并发、工具策略都是 enforcement,不靠模型听话
- 异常、协议修补、结果裁剪集中在执行边界,工具本身只管业务
- 主循环依旧是干净的 model ↔ tools
代价
after_model逆序 +wrap_tool_call嵌套,执行顺序高度依赖注册位置- 不少 middleware 状态在内存里(循环窗口、熔断、reminder 簿记),跨进程行为取决于 run 落在哪
- prompt 约定 ≠ runtime 保证:clarification 的”只澄清”在混合 tool_calls 下并不被强制
易误解的要点
- 要让某个 middleware 在回答向外返回时最先执行,应注册到最后。
after_model逆序派发,注册越靠后执行越早——Safety 正是据此排到首位。提前注册反而会使它最后执行。 - 改写该层消息前,必须确认 message id 不变。 SubagentLimit、TokenUsage、SafetyFinishReason、LoopDetection 均依赖”复用原 id → reducer 原地替换”。一旦 id 改变,reducer 将视其为新消息追加,导致
tool_calls重复、配对协议失效。 goto=END不保证取消同组其他工具,是否终止取决于return_direct。 仅当本轮客户端工具全部为return_direct时才停于 END;ask_clarification与普通工具混合下发,则回流至模型。“仅澄清”属 prompt 约定,非 runtime 强制。- soft warning 既无法即时注入,也不等同于已拦截。 此刻配对 ToolMessage 尚未生成,直接注入会被 provider 拒收,故须入队,延至下一次
wrap_model_call注入;且其仅追加一条提示,工具仍照常执行。阻止图进入工具节点的是 hard stop。将二者混同,便会误判”检测到循环即已终止”。 - SafetyFinishReason 不对内容安全性做判定。 它仅消费 provider 已给出的停止信号(content_filter / refusal / SAFETY…),依赖它拦截有害内容是误用。
- 包裹工具的 middleware 必须放行
GraphBubbleUp。 它是 LangGraph 的中断 / 恢复信号,而非工具失败;若被except一并捕获,中断 / 恢复将失效。捕获工具异常时务必首先except GraphBubbleUp: raise。 - ToolOutputBudget 约束的是
ToolMessage.content的体积,而非磁盘文件大小。 写入大文件却仅返回简短结果不会触发;治理对象是将大段日志 / HTML / grep 结果直接置入 content 的情形(Command.update内夹带的 ToolMessage 同样处理)。 - release ≠ 销毁。
after_agent调用provider.release(sandbox_id),但LocalSandboxProvider的 release 为 no-op,本地 sandbox 会保留复用。不要因为 run 结束,就以为 sandbox 已被销毁。
两站合起来,一次 run 的中间件全貌就清楚了:模型调用前是准备层,模型返回后是裁决层。其中被反复点到、却一直没展开的,是 sandbox——它既是工具的执行环境,又牵着 acquire / persist / release 一整条生命周期。下一站进入 sandbox:local、container、Kubernetes 到底隔离了什么,又没隔离什么。