第 3 章
Agent 装配
Middleware 为什么必须按顺序排列
本章只回答一个问题:同一套代码为什么能为每次 Run 生成能力不同的 Agent?DeerFlow 不会复制一套 Agent 类,而是根据本次运行的身份和权限,选定模型、工具、提示、状态 schema 与 Middleware,再把它们组装成一张可执行 graph。
同一个模型—工具循环,会在读取输入、调用模型、执行工具和结束运行时经过不同检查。
- 01输入与上下文
清洗输入,挂载 thread、Sandbox 与动态上下文
- 02模型边界
压缩、合并与检查送往模型的请求和返回
- 03工具边界
授权、短路、错误、进度、receipt 与审计
- 04终止边界
空响应、长度、安全、循环、预算与澄清
每次 Run 都重新回答“这个 Agent 是谁”
输入仍是同一个 sales-review 案例:sales-notes.pdf 已经上传,用户要求补充证据并生成 /mnt/user-data/outputs/sales-review.md。Chapter 02 的 worker 在本次 Run 进入 running 后调用 agent factory;标准路径用 assemble_lead_agent,而不是从某个跨 Run 的全局 Agent 对象继续执行。
动作从合并 RunnableConfig 开始。工厂把 configurable 与 runtime context 解析成这次调用可用的配置,再选择 app_config、checkpoint channel mode 和对应的状态 schema。相同 Python 源码面对不同用户、thread、模型选择、agent_name 或运行开关,会得到不同的装配输入。
这里的“每次”指每次工厂被 Run worker 调用时完成一次 assembly,而不是每个 model turn 都重新创建 graph。一次装配完成后,worker 对返回 graph 调用 agent.astream,graph 内部才反复运行模型—工具循环。
def assemble_lead_agent(
config: RunnableConfig,
*,
app_config: AppConfig | None = None,
) -> LeadAgentAssembly:
"""Return the compiled lead graph together with its assembly descriptor.
Gateway workers use this explicit assembly result so what the agent was
built from does not have to be recovered from LangGraph private runtime
keys or mutable graph attributes. ``make_lead_agent`` remains the
graph-only LangGraph Server ABI declared in ``langgraph.json``.
"""
runtime_config = _get_runtime_config(config)
runtime_app_config = app_config or runtime_config.get("app_config")
if not isinstance(runtime_app_config, AppConfig):
runtime_app_config = get_app_config()
# Mode selection precedence, pinned by test_checkpoint_mode.py:
# - First freeze: the app config owns the process mode; a client-supplied
# configurable key is ignored so a direct LangGraph request cannot
# reconfigure (or crash) a fresh process.
# - Once frozen: an internally injected key (run worker / gateway) or the
# app config must match the frozen mode; ``freeze_checkpoint_channel_mode``
# fails closed on any mismatch, so neither a forged key nor a config.yaml
# change can silently reconfigure the process.
frozen_mode = frozen_checkpoint_channel_mode()
if frozen_mode is None:
requested_mode = runtime_app_config.database.checkpoint_channel_mode
else:
requested_mode = (config.get("configurable", {}) or {}).get(
INTERNAL_CHECKPOINT_MODE_KEY,
runtime_app_config.database.checkpoint_channel_mode,
)
mode = freeze_checkpoint_channel_mode(requested_mode)
# The snapshot cadence travels with the mode: restart-required, frozen
# from the app config, and deliberately not client-injectable (a forged
# configurable key must not recompile the channel table either).
freeze_checkpoint_snapshot_frequency(runtime_app_config.database.checkpoint_delta.snapshot_frequency)
inject_checkpoint_mode(config, mode)
return _assemble_lead_agent(config, app_config=runtime_app_config)源码返回 LeadAgentAssembly,其中 graph 是可执行结果;当装配观察能力启用时,descriptor 还描述构造依据。assemble_lead_agent 最后进入 _assemble_lead_agent,说明这个边界负责建图,不负责替 worker 决定 Run 的 success、cancel 或 delivery receipt。
工厂输出的不是一个静态角色名,而是一张已经确定模型、工具和 Middleware 组合的 graph。Agent 能做什么,由本次装配结果决定,不由类名决定。sales-review 这次 Run 可以读取上传材料并写入 outputs;另一次 non_interactive 后台 Run 则不会暴露需要人类即时回答的澄清工具。
装配也可能在模型运行前失败:checkpoint 模式冲突、没有可用模型、Extension 打乱必要顺序,或者授权配置无法安全解析,都会立即停止。此时不能伪装成模型已经回答,更不能因为使用同一份源码,就假定不同 Run 拥有相同能力。
Run 配置先解析成模型、工具、提示、状态与策略;create_agent 再把它们接成可执行 graph。
用户选择和服务器限制共同决定 Agent 配置
一次 assembly 的输入不只有用户选择的 model。_get_runtime_config 合并 configurable 与 context 后,工厂还解析 thinking、reasoning、plan mode、subagent 开关和 agent_name;自定义 Agent 配置可以收紧 model、tool groups、skills 与 allowed_subagents,却不能被一次请求反向放宽服务器限制。
同一条“分析销售下滑”请求若路由到默认 lead agent,会使用默认工具组与提示;若明确路由到一个受限的研究 Agent,则它的模型默认值、skill allowlist 和可委派对象会参与装配。这里的 custom agent 是工厂的一条配置分支,不是另一套 Run 生命周期。
non_interactive 是另一个边界例子。只有内部可信调用路径可以让该上下文生效;生效时 ask_clarification 在工具候选进入授权前就被排除。后台任务不会装出一个明知无法等人回答、却仍向模型暴露澄清工具的 Agent。
当注册了 assembly observer 时,工厂建立 assembly descriptor:它记录有效模型、渲染后提示的 hash、授权后工具名、Middleware 顺序、deferred 工具和 enabled skills。fingerprint 对工具与技能排序,却保留 Middleware 顺序,因为前两者的目录顺序通常不改变能力,后者的先后会改变 wrapper 语义。
descriptor 只是可选的观测结果,不是 graph 执行前必须写入的数据库记录;没有 observer 时可以不生成它,Agent 仍照常运行。这样既能记录“这次到底装了什么”,又不必事后从 LangGraph 私有字段反推。
可以这样理解三者的关系:请求说明用户想做什么,并带上允许选择的运行选项;服务器配置给出默认值和上限;工厂据此算出本次真正生效的组合。配置会直接决定 graph 怎样生成,并非给模型看的建议。模型、角色或 checkpoint 模式只要校验失败,assembly 就会立即停止。
先决定目录里有什么,再守住每次真实调用
工具输入先是一份候选目录:Sandbox 文件工具、内建工具、MCP、community 能力和可选 delegation 根据配置汇合;自定义 Agent 的 tool groups、non_interactive 过滤以及 enabled skills 的发现范围会先收窄候选集。此时“存在于代码库”仍不等于“本次模型可调用”。
第一道边界发生在装配时。apply_tool_authorization 使用本次 Principal 对 authorization_candidates 做 catalog 过滤,得到 authorized_tools;只有留下来的 configured tools 才进入 deferred tool assembly。由此生成的 tool_search 目录也从已授权候选构造,catalog hash 则把后续 promotion 绑定到这份目录。
第二道边界发生在执行时。装配时过滤回答“哪些 schema 可以进入本次能力目录”;GuardrailMiddleware 复用授权 provider,在实际 tool_call 到达时回答“这次调用现在是否允许”。外部 guardrail 若启用会继续独立检查。两层针对的时间和对象不同,不能因为 catalog 已筛过就删除执行入口的 guardrail。
final_tools, setup = assemble_deferred_tools(configured_tools, enabled=resolved_app_config.tool_search.enabled)
final_tools.extend(late_tools)
mcp_routing_middleware = build_mcp_routing_middleware(
final_tools,
setup,
top_k=resolved_app_config.tool_search.auto_promote_top_k,
)
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(authorized_tools, deferred_names=setup.deferred_names)
middlewares = build_middlewares(
config,
model_name=model_name,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id,
authorization_provider=_authz_provider,
subagent_execution_capacity=subagent_execution_capacity,
)
system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
max_total_subagents=max_total_subagents,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
mcp_routing_hints_section=mcp_routing_hints_section,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents,
subagent_execution_capacity=subagent_execution_capacity,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False, model_overrides=agent_model_overrides),
tools=final_tools,
middleware=normalize_middleware_state_schemas(middlewares, mode),
system_prompt=system_prompt,
state_schema=get_thread_state_schema(mode),
)随后 final_tools、middlewares、system_prompt 与 state_schema 一起进入 create_agent。代码里的 final_tools 是授权和 deferred 处理后的结果,不是 raw_tools 的别名;system_prompt 也使用相同的 deferred names、技能范围与 delegation 边界,所以提示声称的能力应与真实目录对齐。
SkillToolPolicyMiddleware 再处理“技能已真正激活后”的动态范围:它同时收紧模型可见 schema、真实执行和 tool_search 返回。enabled skill 只意味着可被发现,不自动给它声明的全部工具授权;这条运行时策略也不能把装配时已经过滤掉的工具重新带回来。
在本例中,模型可能看到 read_file、搜索和写文件能力,但这只表示它们进入了本次工具目录。若某次调用的参数、身份或安全规则不允许执行,guardrail 可以直接返回 ToolMessage,不会进入真实 callable。授权 provider 报错、目录冲突或 schema 无法装配,都会在这里中止建图。
Middleware 的顺序会改变最终结果
不要把 build_middlewares 当成几十项功能的清单。更容易读懂的方法,是按四个问题来分:哪些输入和上下文可以进入;发给模型的请求和模型返回怎样处理;工具调用怎样授权、执行和记录;循环在什么条件下必须停止。每个 Middleware 只会在自己实现的 hook 上起作用。
输入与上下文层从 InputSanitizationMiddleware 开始,随后挂接 ThreadData 与 SandboxMiddleware,再由 lead-only 的 DynamicContextMiddleware 提供本次日期和可选上下文。Sandbox 在这里仅说明初始化位置:通常以 lazy_init 挂入 before_agent 或工具路径,本章不展开 provider acquisition、挂载和 release 算法。
模型层负责送给 provider 的上下文形状和返回信号。Durable context 之后可挂 SummarizationMiddleware,SystemMessageCoalescingMiddleware 靠近物理模型调用;MemoryMiddleware 位于 lead stack 的后段,负责装配其 hook。这里仅定位 Memory、Summary 与 Sandbox,连续性算法属于 Chapter 05,执行环境算法属于 Chapter 04。
wrapper 从外向内进入、从内向外返回;after_model 则由框架按注册逆序接边。
# Layer 1 — outermost wrap_model_call wrappers (listed outer→inner).
# InputSanitizationMiddleware is first so it becomes the outermost
# wrapper — sanitised messages are what every inner middleware sees.
# ToolResultSanitizationMiddleware mirrors that guardrail for the other
# untrusted-content entry point: remote tool results (web_fetch /
# web_search) get the same framework/injection-tag neutralization. It sits
# inner of ToolOutputBudgetMiddleware (listed after it) so it neutralizes
# the raw tool output first; the budget wrapper then truncates the already
# neutralized text.
outer_wrappers: list[AgentMiddleware] = [
InputSanitizationMiddleware(),
ToolOutputBudgetMiddleware.from_app_config(app_config),
ToolResultSanitizationMiddleware(),
]
# Layer 2 — before_agent hooks that read/annotate thread-scoped data.
thread_hooks: list[AgentMiddleware] = [
ThreadDataMiddleware(lazy_init=lazy_init),
]
if include_uploads:
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
thread_hooks.append(UploadsMiddleware())
thread_hooks.append(SandboxMiddleware(lazy_init=lazy_init))
# Layer 3 — post-processing append-only middlewares.
tail: list[AgentMiddleware] = []
if include_dangling_tool_call_patch:
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
tail.append(DanglingToolCallMiddleware())
tail.append(LLMErrorHandlingMiddleware(app_config=app_config))
# ToolReceiptMiddleware is the outermost wrap_tool_call layer: Guardrail,
# SandboxAudit, ReadBeforeWrite, and ToolProgress can all short-circuit a
# call with their own ToolMessage, and SandboxAudit rebuilds medium-risk
# results — an inner receipt layer would miss those results and silently
# gap the ledger. Stamping out here still sees deerflow_tool_meta on
# normal results (ToolErrorHandling stamps it on the inner return path)
# and on self-stamped short-circuit messages; the remainder fall back to
# message.status (see make_tool_receipt).
verification_config = app_config.verification
if verification_config.receipts_enabled:
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
tail.append(ToolReceiptMiddleware(render_mode=receipts_render_mode))
# Authorization uses the existing GuardrailMiddleware so execution-time
# deny, audit, and fail-closed handling stay in one proven implementation.
# It is appended before an explicit guardrail provider, making authorization
# the outer guard and avoiding an unnecessary external policy call for an
# already-denied tool.
authorization_config = app_config.authorization
if authorization_config.enabled is True:
if authorization_provider is None:
from deerflow.authz.runtime import resolve_authorization_provider
authorization_provider = resolve_authorization_provider(authorization_config)
if authorization_provider is not None:
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
from deerflow.guardrails.middleware import GuardrailMiddleware
tail.append(
GuardrailMiddleware(
GuardrailAuthorizationAdapter(
authorization_provider,
default_role=authorization_config.default_role,
infrastructure_tool_names=authorization_infrastructure_tool_names,
),
fail_closed=authorization_config.fail_closed,
)
)
# Explicit guardrail middleware remains independently active when configured.
guardrails_config = app_config.guardrails
if guardrails_config.enabled and guardrails_config.provider:
import inspect
from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.reflection import resolve_variable
provider_cls = resolve_variable(guardrails_config.provider.use)
provider_kwargs = dict(guardrails_config.provider.config) if guardrails_config.provider.config else {}
# Pass framework hint if the provider accepts it (e.g. for config discovery).
# Built-in providers like AllowlistProvider don't need it, so only inject
# when the constructor accepts 'framework' or '**kwargs'.
if "framework" not in provider_kwargs:
try:
sig = inspect.signature(provider_cls.__init__)
if "framework" in sig.parameters or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
provider_kwargs["framework"] = "deerflow"
except (ValueError, TypeError):
pass
provider = provider_cls(**provider_kwargs)
tail.append(GuardrailMiddleware(provider, fail_closed=guardrails_config.fail_closed, passport=guardrails_config.passport))
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
tail.append(SandboxAuditMiddleware())
# ReadBeforeWriteMiddleware is the outermost write gate: it blocks writes to files
# the model hasn't read in their current version. It must sit outside ToolProgress
# and ToolErrorHandling so that a blocked write returns immediately without consuming
# a ToolProgress slot. The middleware stamps deerflow_tool_meta on the blocked
# ToolMessage itself so downstream callers receive a well-formed result.
if app_config.read_before_write.enabled:
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
tail.append(ReadBeforeWriteMiddleware())
# ToolProgressMiddleware must be outer (lower index) so its wrap_tool_call handler
# chain includes ToolErrorHandlingMiddleware (inner), which stamps deerflow_tool_meta
# on every result before ToolProgressMiddleware reads it in _update_state_from_result.
# Framework rule: first in list = outermost (types.py: "compose with first in list as outermost layer").
tool_progress_config = app_config.tool_progress
if tool_progress_config.enabled:
from deerflow.agents.middlewares.tool_progress_middleware import ToolProgressMiddleware
tail.append(ToolProgressMiddleware.from_config(tool_progress_config))
tail.append(ToolErrorHandlingMiddleware(app_config=app_config))
middlewares = [*outer_wrappers, *thread_hooks, *tail]
# Ordering invariants are declared in deerflow.extensions.ordering and
# validated once at the end of the composing builder, after extension
# contributions are merged in — otherwise a contribution could silently
# reverse an invariant this builder had already checked.
return middlewares摘录把共享 base 分成 outer_wrappers、thread_hooks 和 tail;这是实现构造方式,不妨碍我们用四层解释职责。InputSanitizationMiddleware 排在列表第一,因此成为 wrap_model_call 的最外层,内层重试和请求变换拿到的已经是清洗后消息。
工具层放置授权 guardrail、Sandbox audit、read-before-write、ToolProgressMiddleware、ToolErrorHandlingMiddleware 与可选 ToolReceiptMiddleware。它们共同回答调用是否可执行、错误如何返回、结果有没有新信息以及最终可见结果留下什么 receipt,而不是为模型增加新的推理步骤。
终止层处理空终答修复、模型长度、安全 finish reason、loop 和 token budget,以及需要人类输入时的 ClarificationMiddleware。active goal 是否继续,由 Run worker 在一次 graph stream 之后判断,并不是某个 goal Middleware 的工作。最关键的是顺序:列表第一项位于 wrapper 最外层,调用进入时从外向内,返回时从内向外;LangChain 的 after_model hooks 也按注册顺序的反方向运行。
两个不可交换的顺序,决定失败能否被看见
先看 receipt。一次搜索若被 GuardrailMiddleware 拒绝,guardrail 会短路并返回自己的 ToolMessage,真实工具 handler 根本不执行;read-before-write、SandboxAudit 或 ToolProgress 也可能短路或重建结果。若 ToolReceiptMiddleware 放在这些策略内侧,控制流没有进入它,它就看不到这次拒绝,ledger 会出现空洞。
正确顺序让 ToolReceiptMiddleware 在外层包住短路策略和错误层。正常工具抛异常时,内层 ToolErrorHandlingMiddleware 把异常转换为带 deerflow_tool_meta 的 error ToolMessage;控制流向外返回后,receipt 看到的已经是模型最终可见的失败状态。短路结果若自行带 meta 也被记录,否则再回退到 message.status。
因此互换不是“日志先后不同”这么轻。Receipt 在内层时,销售报告的某个 web search deny 可能对模型可见,却没有相应 receipt;Receipt 在错误层内侧时,也拿不到 ToolErrorHandlingMiddleware 归一化后的 status。可观测账本与实际工具结果会分叉。
return (
OrderingConstraint(
outer=ToolProgressMiddleware,
inner=ToolErrorHandlingMiddleware,
reason=("ToolProgressMiddleware reads deerflow_tool_meta in _update_state_from_result, so its wrap_tool_call chain must enclose the ToolErrorHandlingMiddleware step that stamps it"),
),
OrderingConstraint(
outer=ToolReceiptMiddleware,
inner=ToolErrorHandlingMiddleware,
reason=("ToolReceiptMiddleware reads the deerflow_tool_meta status stamped by ToolErrorHandlingMiddleware when building each receipt, so its wrap_tool_call chain must enclose the stamping step"),
),
*(
OrderingConstraint(
outer=ToolReceiptMiddleware,
inner=short_circuiter,
reason=(f"{short_circuiter.__name__} can return or rebuild a ToolMessage without invoking its handler; ToolReceiptMiddleware must wrap it or those results never get a receipt and the ledger silently gaps"),
)
for short_circuiter in (
GuardrailMiddleware,
SandboxAuditMiddleware,
ReadBeforeWriteMiddleware,
ToolProgressMiddleware,
)
),
)再看进度。ToolProgressMiddleware 必须在 ToolErrorHandlingMiddleware 外层:调用进入时先过 progress,异常在更内层被转换并盖上 deerflow_tool_meta,返回时 progress 才能按 error_type、recoverable_by_model 和 recommended action 更新停滞状态。
若把错误层放到 progress 外面,原始异常先穿过 progress 并使其退栈,等到外层才被转换;模型最后仍可能得到 error ToolMessage,但 Progress 没有看到归一化结果。用户会失去“失败是否仍在产生新信息、是否该警告或阻断”的连续信号,这就是错误层位置带来的可观测差异。
固定提交没有只靠注释维持这些关系。core_ordering_constraints 声明 ToolProgressMiddleware 与 ToolReceiptMiddleware 必须 outer of ToolErrorHandlingMiddleware,并要求 receipt 包住所有列出的 short_circuiter;compose_with_extensions 在最终 stack 上调用 assert_ordering,扩展注入后仍违反关系就硬失败。
模型轴也有同样的方向意识:Safety middleware 在列表中后注册,所以 after_model 逆序时先看到原始 safety-terminated response,清掉不完整 tool_calls 后,较早注册的 loop accounting 才观察清理后的消息。正向进入、反向返回和 reverse hook 都必须在审查中明确说出。
侧栏:Extension 声明语义位置,不声明脆弱下标
Extension middleware 面对的问题是:宿主内建列表会调整,第三方代码若说“插到第 17 项”,一次重构就改变含义。DeerFlow 在这个实现中让贡献者声明 model logical、model physical、tool visible、tool raw 或 standard,再由宿主把语义 placement 翻译成当前 stack 的 anchor。
MODEL_LOGICAL 位于模型重试外侧,用于一次逻辑决策只观察一次;MODEL_PHYSICAL 靠近最终 provider request,重试会再次经过。TOOL_VISIBLE 在工具轴外侧观察模型最终看到的结果;TOOL_RAW 靠近真实 callable,观察处理前返回。它们是 hook 轴上的保证,不表示一个 middleware 实现所有 hook。
必须等 lead-specific middleware 全部追加完,才能插入 Extension。若在 base builder 中过早插入 MODEL_PHYSICAL,后来追加的请求变换就会跑到它里面,它看到的便不再是实际发给 provider 的最后一次请求。因此,compose_with_extensions 必须在列表完整后执行。
return {
# Outer of the retry loop, so one logical decision stays one event even
# when LLMErrorHandlingMiddleware retries underneath.
Placement.MODEL_LOGICAL: outer_of(LLMErrorHandlingMiddleware),
# Inner of every lead-agent request transform. Deliberately NOT
# innermost(): ClarificationMiddleware sits inner of this point today,
# and moving the anchor past it would change what "the final request"
# means.
Placement.MODEL_PHYSICAL: PlacementAnchor.of(
inner_of_last_after(
SafetyFinishReasonMiddleware,
after=(TerminalResponseMiddleware,),
),
inner_of_last(TerminalResponseMiddleware),
outer_of_last(ClarificationMiddleware),
innermost(),
),
Placement.TOOL_VISIBLE: outermost(),
# As close to the tool callable as the chain allows. Deliberately NOT
# inner_of(ToolErrorHandlingMiddleware): SkillToolPolicyMiddleware and
# ClarificationMiddleware are appended later and also wrap tool calls,
# so anchoring there left two wrappers inner of "raw" and the placement
# silently stopped meaning what it says.
#
# ClarificationMiddleware remains the one carve-out, the same shape as
# MODEL_PHYSICAL's above: it must stay last (it short-circuits the tool
# loop with Command(goto=END)), and it only ever intercepts
# ask_clarification — it does not transform the result of any tool that
# actually executes, so TOOL_RAW still sees raw results.
Placement.TOOL_RAW: PlacementAnchor.of(
outer_of_last(ClarificationMiddleware),
innermost(),
),
Placement.STANDARD: PlacementAnchor.of(
outer_of(LLMErrorHandlingMiddleware),
innermost(),
),
}摘录中的 Placement anchors 还提供了明确的 fallback;找不到 primary anchor 时会生成 diagnostic,不会悄悄假装两种位置完全等价。组合完成后还要执行 assert_ordering,所以 Extension 不能破坏 DeerFlow 对 receipt、progress 与 error 顺序的规定。
信任边界必须单独说清:Python Extension 是 operator 配置的 trusted host code,构建 hook 和导入后的代码都以 Gateway 权限执行。它不是 Sandbox 中的低权限业务工具,也不能因为 IsolatedMiddleware 会隔离普通 extension hook 失败,就被描述成不可信代码的安全容器。
本章只讨论 middleware placement。Extension 还可以贡献 observer、service 或 router,但它们不参与这里的 model-tool wrapper 顺序;把所有贡献类型混成“插件列表”反而会掩盖为何 middleware 需要语义位置。
面试时按这条装配顺序来讲
回到 sales-review:输入是这次 Run 的身份、模型选择、自定义 Agent 边界、非交互标记和服务器配置;动作是解析 enabled skills 与候选工具,先做 catalog 授权,再生成 deferred setup、提示、状态 schema 和 ordered middleware;输出是交给 agent.astream 的 executable graph。
代码卡从 build_lead_runtime_middlewares 返回的 base 继续追加 lead-specific stack:DynamicContext、Skill activation 与 policy、durable context、可选 Summary/Todo/Token、Title、Memory,再到 MCP routing、deferred filter 与 system-message coalescing。它展示的不是一套永远全开列表:可选项由配置决定,但相对位置仍要满足固定约束。
middlewares = build_lead_runtime_middlewares(**runtime_middleware_kwargs)
# Always inject current date (and optionally memory) as <system-reminder> into the
# first HumanMessage to keep the system prompt fully static for prefix-cache reuse.
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
# Deterministically load a full SKILL.md when the user starts the turn with
# /skill-name. This keeps the base system prompt metadata-only while giving
# explicit user activation priority over model-side relevance guessing.
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
slash_source_owner_token = secrets.token_urlsafe(24)
middlewares.append(
SkillActivationMiddleware(
available_skills=available_skills,
app_config=resolved_app_config,
user_id=user_id,
slash_source_owner_token=slash_source_owner_token,
)
)
# Enabled skills are only discoverable metadata. Apply allowed-tools at
# runtime after explicit slash activation or an actual skill-file load.
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
middlewares.append(
SkillToolPolicyMiddleware(
available_skills=available_skills,
app_config=resolved_app_config,
user_id=user_id,
slash_source_owner_token=slash_source_owner_token,
)
)
# Capture completed task delegations and loaded skill files before
# summarization can compact them, then inject durable context channels
# (summary + ledger + skills) into model calls.
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
middlewares.append(
DurableContextMiddleware(
skills_container_path=resolved_app_config.skills.container_path,
skill_file_read_tool_names=resolved_app_config.summarization.skill_file_read_tool_names,
)
)
# Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(
app_config=resolved_app_config,
run_model_name=model_name,
extensions=resolved_extensions,
)
if summarization_middleware is not None:
middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled
cfg = _get_runtime_config(config)
is_plan_mode = cfg.get("is_plan_mode", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None:
middlewares.append(todo_list_middleware)
# Add TokenUsageMiddleware when token_usage tracking is enabled
if resolved_app_config.token_usage.enabled:
middlewares.append(TokenUsageMiddleware())
# Add TitleMiddleware
middlewares.append(
TitleMiddleware(
app_config=resolved_app_config,
extensions=resolved_extensions,
)
)
# Add MemoryMiddleware after TitleMiddleware. Tool mode normally skips it;
# conversation-extraction backends may explicitly retain passive writes.
if should_use_memory_tools(resolved_app_config.memory):
from deerflow.agents.memory.manager import backend_requires_passive_writes_in_tool_mode
if backend_requires_passive_writes_in_tool_mode(resolved_app_config.memory.manager_class):
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
else:
if resolved_app_config.memory.mode == "tool" and not resolved_app_config.memory.enabled:
logger.warning("memory.mode is 'tool' but memory.enabled is false; memory tools will not be registered.")
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
model_config = resolved_app_config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
middlewares.append(ViewImageMiddleware())
# Auto-promote deferred MCP schemas from PR1 routing metadata before the
# deferred filter decides which schemas to hide for this model call.
if mcp_routing_middleware is not None:
middlewares.append(mcp_routing_middleware)
# Hide deferred tool schemas from model binding until tool_search promotes them.
# The lead deferred set + catalog hash come from the full build-time MCP
# catalog; SkillToolPolicyMiddleware separately filters model visibility,
# tool_search results, and execution for the active skill at runtime.
if deferred_setup is not None and deferred_setup.deferred_names:
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
from deerflow.agents.middlewares.mcp_routing_middleware import assert_mcp_routing_before_deferred_filter
assert_mcp_routing_before_deferred_filter(middlewares)
# Coalesce every SystemMessage into a single leading one before the request
# reaches the provider. Strict backends (vLLM, SGLang, Qwen, Anthropic)
# reject non-leading SystemMessages. See system_message_coalescing_middleware.py.
from deerflow.agents.middlewares.system_message_coalescing_middleware import SystemMessageCoalescingMiddleware
middlewares.append(SystemMessageCoalescingMiddleware())完整 stack 的终止 tail 仍会追加 TerminalResponseMiddleware、长度与 safety 检查;ClarificationMiddleware 必须保持最后注册,因为它可能把 ask_clarification 转成 ToolMessage 与 Command(goto=END),并丢弃同轮 sibling calls。non_interactive Run 则更早从 catalog 移除该工具:前者是执行期短路,后者是装配期不暴露。
如果 graph 构建成功,模型看到的是 final_tools 和 system_prompt 声明的同一能力边界;实际调用仍经过 SkillToolPolicyMiddleware、authorization adapter 和可选外部 guardrail。若顺序 assertion 失败,正确结果是中止装配,而不是带着不可预测账本继续运行。
[LangChain 的官方 middleware 概览](https://docs.langchain.com/oss/python/langchain/middleware/overview)同样把 before/after agent、model 与 tool hooks 作为控制 Agent 行为的扩展点。DeerFlow 真正值得讲的不是发明了 Middleware,而是它为每次 Run 重新确定身份,先后执行两层授权,让 Extension 按语义选择插入位置,并在建图结束时检查必要的顺序。
失败排查也因此有顺序:模型没有某工具,先查 catalog、custom agent 与 non_interactive;模型有 schema 却调用被拒,查执行 guardrail 与 Skill policy;错误已返回但没有 progress 或 receipt,查 wrapper 顺序与短路路径;graph 根本未产生,查 assembly 配置和 assert_ordering。
- 90 秒表达:DeerFlow 在每次 Run 启动时,用
assemble_lead_agent确定模型、Agent 配置、授权后的工具、提示、ThreadState schema 与Middleware,再由create_agent生成 graph。授权先过滤 catalog,执行时 guardrail 再检查真实调用。Middleware第一项位于 wrapper 外层,返回方向与注册方向相反,所以 Receipt 必须包住短路和错误层,Progress 必须包住 ToolErrorHandling。Extension 只声明语义 placement,完整列表最后还要通过顺序检查。 - 误解一:装配时通过授权,不代表执行时 guardrail 可以省略;它们约束不同时间点。
- 误解二:
Middleware不是可任意交换的功能清单;顺序会改变短路、错误和 receipt 的可观测结果。 - 误解三:Memory、Summary 与 Sandbox 在本章只定位挂载点;它们的连续性和受控执行算法分别属于后续章节。