跳到主要内容

数据截至 (上游 commit 25aa2735dabb)

装配流水线:一次 create_deep_agent 到底做了什么

30 秒导读: create_deep_agent() 本身不跑任何 agent 逻辑,它只做一件事——把「模型 + 中间件栈 + 子 agent 规格 + system prompt」这四样东西按固定规则组装好,然后调 LangChain 的 create_agent 编译成图。本章只讲这条构造期的流水线:谁先谁后、顺序为什么这么排、用户怎么插手、以及 profile 这套「按模型改行为」的旋钮长什么样。各中间件跑起来干什么,是 0306 的事。

引用约定: 本章所有 path:line 相对克隆里的 Python 包目录 libs/deepagents/deepagents/。例如 graph.py:268 的完整路径是 libs/deepagents/deepagents/graph.py:268


1. 先分清两个时期:构造期和运行期

Deep Agents 的代码有一条容易被忽略的分界线。

构造期:你调 create_deep_agent(...) 的那一瞬间。这时候没有任何模型请求发生,纯粹是在拼装配置——决定装哪些中间件、按什么顺序装、system prompt 由哪几段拼成、子 agent 有哪几个。装完就冻结。

运行期:你调 agent.invoke(...) 之后。这时中间件按构造期定好的顺序一层层包住模型调用,文件工具去后端读写,子 agent 被 task 工具拉起来。

本章只讲构造期。判断标准很简单:代码在 create_deep_agent 函数体内执行的,属于构造期。

一句话直觉:create_deep_agent 是装配流水线,不是发动机。 它把零件按图纸装成一台车,钥匙拧不拧是别人的事。

最小的使用示例

from deepagents import create_deep_agent

# 最简:只给模型,其余全默认
agent = create_deep_agent(model="anthropic:claude-sonnet-4-6")

# 稍复杂:加自定义工具、技能目录、权限规则
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-6",
tools=[my_search_tool],
system_prompt="You are a research assistant.",
skills=["/skills/project/"],
memory=["/memory/AGENTS.md"],
)

上面这两次调用背后,create_deep_agent 至少做了 6 件事、装了 4~13 个中间件、拼了一段「用户 system_prompt → profile base → profile 后缀」的 system prompt、还偷偷塞了一个叫 general-purpose 的子 agent。下面把这些一件件拆开。


2. 顶层全景:六步装配

create_deep_agent 的函数体在 graph.py:268-944(符号 create_deep_agent)。这 677 行按执行顺序可以切成六步:

调用方参数


┌──────────────────────────────────────────────┐
│ ① 解析模型 │
│ model=None → 弃用警告 + 默认 sonnet │
│ str → resolve_model (走 ProviderProfile) │
│ BaseChatModel → 原样 │
└────────────────┬─────────────────────────────┘

┌──────────────────────────────────────────────┐
│ ② 查 harness profile │
│ 按 provider:model 查表 → _profile │
│ (后面每一步都要读它) │
└────────────────┬─────────────────────────────┘

┌──────────────────────────────────────────────┐
│ ③ 定后端 │
│ backend or StateBackend() │
└────────────────┬─────────────────────────────┘

┌──────────────────────────────────────────────┐
│ ④ 建子 agent 规格 │
│ 分流三种形态 → 各自建独立中间件栈 │
│ + 自动补 general-purpose │
└────────────────┬─────────────────────────────┘

┌──────────────────────────────────────────────┐
│ ⑤ 拼主 agent 中间件栈 │
│ 核心段 → 用户插点 → 尾部段 → 排除过滤 │
└────────────────┬─────────────────────────────┘

┌──────────────────────────────────────────────┐
│ ⑥ 拼 system prompt(三段) │
│ 用户 system_prompt → profile base → suffix │
└────────────────┬─────────────────────────────┘

create_agent(...) → CompiledStateGraph

怎么读这张图:从上到下就是代码里的执行顺序,每个框对应 create_deep_agent 函数体的一段。注意 ④ 在 ⑤ 之前——子 agent 的栈先建,主 agent 的栈后建。

index 的「主线走一遍」把同一条流水线切成 7 步(模型与 profile 各占一步、排除过滤与交棒各自单列);本章按函数体的代码块归并成 6 步 + 收尾。两边指的是同一段代码,只是切法不同。

各步的代码落点:

步骤干什么关键符号位置
① 解析模型字符串 spec 变成 BaseChatModelresolve_model_models.py:35-57
② 查 profile按模型找 harness 行为配置_harness_profile_for_modelprofiles/harness/harness_profiles.py:1252-1322
③ 定后端文件与执行落在哪StateBackendgraph.py:627
④ 子 agent 规格三种形态分流 + 补默认内联循环、GENERAL_PURPOSE_SUBAGENTgraph.py:645-814
⑤ 主中间件栈三段拼 + 过滤_apply_custom_middlewaregraph.py:816-909
⑥ prompt 拼装三段合一_apply_profile_promptgraph.py:911-920
收尾编译成图create_agentgraph.py:922-944

收尾那一下还顺手盖了两个章:recursion_limit=9_999(把 LangGraph 默认的递归上限抬到近乎不限),以及 ls_integration: "deepagents" 之类的追踪元数据(graph.py:935-943)。

状态 schema 默认是 DeepAgentStategraph.py:70-73)——它在 messages 上挂了 DeltaChannel,把 checkpoint 的增长从 O(N²) 压到 O(N)。这条属于上下文工程,细节见 05-context-engineering


3. 第一步:模型是怎么来的

3.1 三条入口

model 参数接受三种东西,走三条路(graph.py:584-604):

传入处理依据
BaseChatModel 实例原样使用,_model_spec = Nonegraph.py:584_models.py:54-55
"provider:model" 字符串resolve_modelinit_chat_model_model_spec 记住原串graph.py:604
None发弃用警告 + 造默认 claude-sonnet-4-6graph.py:586-602

_model_spec 这个变量看着不起眼,但很关键:只有字符串入口才留得下 spec 原文,第二步查 profile 时能直接拿它当 key;传实例进来就只能靠反射猜(见 §4.3)。

3.2 ProviderProfile:构造模型时的旋钮

resolve_model 本体只有三行有效代码(_models.py:54-57):

if isinstance(model, BaseChatModel):
return model
return init_chat_model(model, **apply_provider_profile(model))

apply_provider_profileprofiles/provider/provider_profiles.py:318-380)负责把注册过的 ProviderProfile 翻译成 init_chat_model 的 kwargs。一个 ProviderProfile 有三个字段(provider_profiles.py:79-130):

字段干什么典型用途
init_kwargs静态 kwargsuse_responses_api=True
pre_init构造前的副作用回调,抛异常就中止构造最低版本检查
init_kwargs_factory每次解析时现算的 kwargs读环境变量、造新的 header 字典

优先级是 init_kwargs < init_kwargs_factory() < 调用方显式 kwargs(provider_profiles.py:376-379)。

内置的三个 provider profile 正好演示了三种用法:

  • openaiinit_kwargs={"use_responses_api": True},默认走 Responses API(profiles/provider/_openai.py:19-24)。
  • nvidia → 用 factory 每次生成新的 default_headers 字典做 app 归因,避免跨实例共享嵌套 mapping(profiles/provider/_nvidia.py:29-49)。
  • openrouterpre_initcheck_openrouter_version,装的 langchain-openrouter 低于 0.2.0 就直接抛 ImportErrorprofiles/provider/_openrouter.py:89-128)。

3.3 和 harness profile 的分工

这是两套长得很像、职责完全不同的注册表,一句话切分:

ProviderProfileHarnessProfile
管什么模型怎么造模型造好之后 harness 怎么跑
消费者resolve_modelcreate_deep_agent
典型字段init_kwargs / pre_initsystem_prompt_suffix / excluded_tools / extra_middleware
注册表_PROVIDER_PROFILES_HARNESS_PROFILES
位置profiles/provider/provider_profiles.py:37-163profiles/harness/harness_profiles.py:483-779

两者共用同一套 key 语法(providerprovider:model)和同一个校验函数 validate_profile_keyprofiles/_keys.py:11-41),也共用同一次懒加载 bootstrap(§4.5)。


4. 第二步:harness profile 的解析

4.1 HarnessProfile 有哪些旋钮

HarnessProfile 是个 frozen dataclass,七个字段(profiles/harness/harness_profiles.py:483-779):

字段语义作用在装配的哪一步
base_system_prompt提供 prompt 的 BASE 段(缺省为空——Deep Agents 不再自带默认提示词)⑥ prompt 拼装
system_prompt_suffix追加在最后,贴着对话历史⑥ prompt 拼装
tool_description_overrides按工具名改描述① 用户工具、④⑤ 中间件工具
excluded_tools从模型可见工具集里摘掉⑤ 尾部的 _ToolExclusionMiddleware
excluded_middleware从装好的栈里删中间件⑤ 排除过滤
extra_middleware往每个栈追加中间件④⑤ 尾部段
general_purpose_subagent改(或关掉)自动补的 GP 子 agent

__post_init__ 做了两件防御(harness_profiles.py:724-770):把 tool_description_overrides 拷贝进 MappingProxyType 只读视图、把 extra_middleware 序列冻成 tuple。frozen dataclass 只挡重新绑定属性,不挡改容器内容——没有这一步,注册完之后改一下当初传进去的那个 dict,就能悄悄篡改注册表里的 profile。

extra_middleware 允许是「序列」或「零参工厂」。要取实例一律走 materialize_extra_middleware()harness_profiles.py:772-777),它每次返回新的 list,且工厂形态每次都重新调用——这样同一个 profile 应用到主 agent、GP 子 agent、多个声明式子 agent 时,不会共享同一批中间件实例。

4.2 两个配套类型

  • HarnessProfileConfigharness_profiles.py:192-480)是 HarnessProfile 的「能写进 YAML/JSON 的子集」:只有纯字符串/布尔/列表/嵌套 dict,没有 extra_middlewareexcluded_middleware 也只收字符串名。注册时会自动 to_harness_profile() 转成运行时形态。反向的 from_harness_profile 遇到 extra_middleware 直接抛 ValueError 而不是静默丢弃(harness_profiles.py:460-467)——宁可报错也不悄悄降级
  • GeneralPurposeSubagentProfileharness_profiles.py:83-189)只管那个自动补的 general-purpose 子 agent,三个字段:enabled / description / system_promptenabled三态None = 继承或默认开、True = 强制开、False = 关)——三态是为了让模型级 profile 能把 provider 级 profile 关掉的东西再打开(harness_profiles.py:97-1111178-1193)。

4.3 查表:从模型找到 profile

入口是 _harness_profile_for_model(model, spec)harness_profiles.py:1252-1322),两条路:

spec 是字符串?
├─ 是 ──► _get_harness_profile(spec) ──► 命中就用,否则空 profile

└─ 否(传了预建模型实例)

├─ 1. get_model_provider + get_model_identifier
│ 拼成 "provider:identifier" 查一次
│ (identifier 自带 ':' 就跳过,避免双冒号)
├─ 2. identifier 本身形如 "a:b" → 用它查一次
├─ 3. 只用 provider 查一次
└─ 4. 全不中 → 日志 + 返回 HarnessProfile()(空对象)

这里有个刻意的坑规避:裸标识符(不含冒号)绝不拿去查表。否则某个自建代理的 model_name 恰好叫 "openai",就会莫名其妙继承 OpenAI 的 provider profile(harness_profiles.py:1264-1269)。

日志级别也做了区分:全不中时,如果用户注册过任何非 bootstrap 的 profile,打 WARNING;注册表里只有内置默认,那就打 DEBUGharness_profiles.py:1308-1321,配合 _has_any_harness_profileharness_profiles.py:1028-1046)。这条专治「我注册的 profile 怎么不生效」。

_get_harness_profileharness_profiles.py:1047-1101)本身是两级查表:

  1. 精确 key(anthropic:claude-sonnet-4-6
  2. provider 前缀(anthropic
  3. 两个都在就 _merge_profiles(base=provider, override=exact) 合并

畸形 spec(空串、多个冒号、"openai:" 这种空半边)直接返回 None,不进注册表——防止 "openai:" 静默命中 provider 级的 "openai"

4.4 additive 合并:注册是叠加,不是覆盖

register_harness_profile(key, profile) 的语义是叠加:同 key 已经有注册,新的会被 _merge_profiles 合到旧的上面,而不是替换(harness_profiles.py:960-976)。

_merge_profilesharness_profiles.py:1194-1250)按字段各定各的规则:

字段类型合并规则例子
单值(两个 prompt 字段)override 设了就用 override,否则回落 baseprovider 设 suffix、模型级留 None → 保留 provider 的
描述映射逐 key 合并,override 赢provider 改 task、模型级改 ls → 两个都在
两个排除集合并集{"execute"}{"grep"} → 两个都排除
extra_middleware具体类当身份合并同类原地替换,新类追加到末尾
general_purpose_subagent逐字段合并模型级 enabled=True 能推翻 provider 级的 False

_merge_middlewareharness_profiles.py:1116-1176)值得单独看一眼:它返回的是一个 factory 闭包而不是列表,合并动作推迟到每次 materialize_extra_middleware() 时才做,这样两边如果都是工厂,每次解析都各调一遍。它还处理了一个边角:base 里若意外出现同类的两个实例,只替换第一个、丢掉后面的——语义是「原地替换」,不是「每匹配一次插一次」。

4.5 内置 profile 的懒加载与插件机制

内置 profile 不是在 import 时注册的,而是第一次访问注册表时才 bootstrap,入口 _ensure_builtin_profiles_loaded()profiles/_builtin_profiles.py:103-176)。两个阶段:

第一次 register_* / _get_*_profile 触发


┌──────────────────────────────────────────┐
│ 阶段 1:直接调内置模块的 register() │
│ _nvidia / _openai / _openrouter │
│ _anthropic_opus_4_7 / _sonnet_4_6 │
│ _anthropic_haiku_4_5 / _openai_codex │
│ _nvidia_nemotron_3_ultra │
│ ← 任何异常都往上抛(内置坏了是 bug) │
└────────────────┬─────────────────────────┘

┌──────────────────────────────────────────┐
│ 阶段 2:扫 entry-point 两个组 │
│ deepagents.provider_profiles │
│ deepagents.harness_profiles │
│ ← 第三方失败只记日志 + warn,跳过 │
└────────────────┬─────────────────────────┘

快照 _BOOTSTRAP_HARNESS_KEYS,置 _loaded=True

三个设计点值得抄:

  • 内置走显式 import,不走 entry point_builtin_profiles.py:1-20)。理由写在模块 docstring 里:环境里一个畸形的 dist-info 不该让 SDK 自己的默认值静默失效。
  • 失败要能回滚。bootstrap 前先 dict(...) 存了两个注册表的快照,出异常就 clear() + update() 原地恢复——原地是因为别的模块可能持有同一个 dict 对象的引用(_builtin_profiles.py:145-171)。
  • 并发与重入都考虑了。用 threading.Condition 让第一个线程做 bootstrap、其余线程等;同时记 _loading_thread_id,让 bootstrap 过程中插件回调再调 register_harness_profile 时短路返回,不死锁也不递归(_builtin_profiles.py:85-100133-143)。

_invoke_profile_plugins_builtin_profiles.py:179-235)把第三方失败分成四类,日志级别不同:枚举 entry point 本身炸了记 WARNING(环境问题,赖不到具体插件),ep.load() 炸、目标不可调用、注册回调抛异常都记 ERROR(插件自己的 bug)。且明确声明不保证插件顺序——因为注册语义是叠加,后来者层在先来者之上。

内置 harness profile 大多只设 system_prompt_suffix,但并非全部:_openai_codex 会额外带上 TodoListMiddleware(待办清单由它 opt-in 回来),新增的 _nvidia_nemotron_3_ultra 也是带 extra_middleware 的重量级 profile。举两个例子:

  • openai:gpt-5.1-codex / 5.2 / 5.3 共用一段 Codex 风格 suffix(自主推进、并行工具调用、TODO 收尾),注册在每模型 key 上而不是 "openai" 前缀,免得污染非 Codex 的 OpenAI 模型(profiles/harness/_openai_codex.py:27-3281-88)。
  • anthropic:claude-sonnet-4-6 挂 Anthropic 的通用 Claude 指引(并行工具调用、先读再答、工具结果后反思)(profiles/harness/_anthropic_sonnet_4_6.py:32-51)。

5. 第三步:后端

一行搞定(graph.py:627):

backend = backend if backend is not None else StateBackend()

默认 StateBackend() 意味着文件活在 agent state 里,不落磁盘。这个 backend 对象随后被塞给 FilesystemMiddlewareSkillsMiddlewareMemoryMiddlewareSubAgentMiddleware 和 summarization 中间件——注意主 agent 和所有子 agent 共享同一个 backend 实例graph.py:668-672753-757818-819829-830843864-868)。后端有哪几种、各自把文件放哪,见 02-backends


6. 第五步:中间件栈的三段结构

这是整个装配里最需要讲清楚的一节:顺序即语义

6.1 三段是哪三段

┌─── 核心段(core) ────────────────────────┐
│ Skills? → Filesystem → │
│ SubAgent? → Summarization → │
│ PatchToolCalls → AsyncSubAgent? │
└───────────────┬───────────────────────────┘
│ ← 这里记下 _main_core_names

┌─── 用户段 ────────────────────────────────┐
│ middleware=[...] 里的「新名字」插在这里 │
└───────────────┬───────────────────────────┘

┌─── 尾部段(tail) ────────────────────────┐
│ profile extra → 缓存 → Memory → HITL │
│ → _ToolExclusion(最后) │
└───────────────────────────────────────────┘

? 的是条件装配。核心段的构建在 graph.py:816-851,尾部段在 graph.py:859-893

6.2 主 agent 栈的确切顺序(按代码,不按文档)

#中间件何时存在依据
1SkillsMiddlewareskills is not Nonegraph.py:818-819
2FilesystemMiddleware总是(必备脚手架)graph.py:820-826
3SubAgentMiddlewareinline_subagents 非空graph.py:827-840
4SummarizationMiddleware(别名)总是graph.py:841-846
5PatchToolCallsMiddleware总是graph.py:841-846
6AsyncSubAgentMiddleware有 async 子 agentgraph.py:848-851
用户中间件插点传了 middleware= 且是新名字graph.py:855883
7profile 的 extra_middlewareprofile 配了graph.py:859
8AnthropicPromptCachingMiddleware总是(非 Anthropic 模型自动 no-op)middleware/_prompt_caching.py:43graph.py:860
9BedrockPromptCachingMiddleware装了 langchain-awsmiddleware/_prompt_caching.py:13-24
10FireworksPromptCachingMiddleware装了 langchain-fireworksmiddleware/_prompt_caching.py:27-38
11MemoryMiddlewarememory is not Nonegraph.py:861-870
12HumanInTheLoopMiddleware有 interrupt 配置graph.py:871-876
13_ToolExclusionMiddlewareprofile.excluded_tools 非空graph.py:892-893

待办清单不在表里:TodoListMiddleware 已从默认栈移除(0.7.x 起 opt-in),想要 write_todos 得靠 harness profile 的 extra_middleware(内置 Codex profile 就是这么做的,见 §4.5)。

注意一处文档与代码不一致: middleware 参数的 docstring(graph.py:379-391)把 _ToolExclusionMiddleware 列在尾部段第 2 位(profile extra 之后、prompt caching 之前),而实际代码把它 append 在整个栈最末尾graph.py:892-893),在 HITL 之后。以代码为准。这个位置差异不影响正确性——反正它要的就是「最后」——但按 docstring 的顺序去理解栈会对不上。

几个顺序背后的理由,代码注释都写了:

  • profile extra 排在 Memory 之前,是为了不让「记忆更新改 system prompt」这件事把 Anthropic 的 prompt cache 前缀作废(graph.py:856-858)。
  • _ToolExclusionMiddleware 必须最后。它靠 wrap_model_call 在请求发出前把工具从 request.tools 里过滤掉(middleware/_tool_exclusion.py:45-54)。中间件是洋葱式包裹,越靠后越贴近模型调用——只有放在最后,它才能既删掉用户传的工具、也删掉 filesystem/subagent 这些中间件注入的工具,而且没有任何后来的 wrap_model_call 能把它删掉的工具再加回来graph.py:890-891)。
  • prompt caching 无条件装,靠 unsupported_model_behavior="ignore" 对非目标模型自动空转(middleware/_prompt_caching.py:43)。Bedrock 和 Fireworks 那两个是软依赖:import 不到对应包就返回 None 跳过,但其他 ImportError 照样抛——不能把插件内部的依赖错误伪装成「插件没装」(middleware/_prompt_caching.py:18-2232-36)。

6.3 _main_core_names 的作用

_main_core_names = {m.name for m in deepagent_middleware}

这一行(graph.py:855)夹在核心段建完、尾部段还没 append 的中间——它抓的是「核心段快照」。后面 _apply_custom_middleware(..., core_names=_main_core_names) 就靠这个集合算出用户中间件该插在哪:最后一个核心成员的位置 +1,也就是尾部段之前。

没有这个快照,新来的用户中间件就只能追加到最末尾——那会跑到 _ToolExclusionMiddleware 后面,用户的 wrap_model_call 就有机会把被排除的工具塞回去,排除机制形同虚设。

6.4 自定义中间件的合并规则

_apply_custom_middlewaregraph.py:201-235)只有两条规则:

情况行为依据
.name 与栈里现有的某个重名原地替换,位置不变graph.py:225-228
.name新名字插到最后一个 core_names 成员之后graph.py:229-232
新名字 + core_names is None追加到末尾graph.py:233-234

演示一下这两条规则的效果:

# 示意,非源码
base = [Todo(), Filesystem(), Summarization(), Caching(), Memory()]
core = {"TodoListMiddleware", "FilesystemMiddleware", "SummarizationMiddleware"}
custom = [MyTodo(), MyLogger()] # MyTodo.name == "TodoListMiddleware"

# 结果:MyTodo 顶掉位置 0 的 Todo;MyLogger 插在 Summarization 之后
# [MyTodo, Filesystem, Summarization, MyLogger, Caching, Memory]

重点看:重名 = 换零件(不动位置),异名 = 插队(进核心段末尾)。所以想替换掉内置的 summarization 行为,只要让你的中间件 .name 等于 "SummarizationMiddleware" 即可——这也是为什么私有类 _DeepAgentsSummarizationMiddleware 要挂一个 serialized_name: ClassVar[str] = "SummarizationMiddleware" 的公开别名(middleware/summarization.py:497-505)。

6.5 排除过滤为什么调两次

主栈末尾这段顺序不是随手写的(graph.py:877-889):

_apply_excluded_middleware ← 第 1 次:过滤 SDK 自己装的
_apply_custom_middleware ← 插入/替换用户中间件
_apply_excluded_middleware ← 第 2 次:过滤用户刚插进来的

第二次是必需的:profile 的 excluded_middleware 声明的语义是「这个 harness 里就不该有这玩意」,包括用户通过 middleware=[...] 传进来的实例harness_profiles.py:642-647)。第一次过滤时用户中间件还没进栈,只跑一次就漏了。


7. 第四步:子 agent 规格(构造期视角)

本节只讲「构造期怎么给子 agent 建栈」,子 agent 跑起来的隔离语义在 04-subagents

7.1 三种形态的分流

subagents= 传进来的每一项,靠 dict 里有没有某个 key 来分类(graph.py:647-655):

判别 key形态去向
graph_idAsyncSubAgent(远程后台任务)async_subagents,交给 AsyncSubAgentMiddleware
runnableCompiledSubAgent(已编译好)原样进 inline_subagents
都没有SubAgent(声明式)就地建一整套中间件栈,再进 inline_subagents

只有第三种要装配。前两种一个是别人编译好的图、一个跑在远端,create_deep_agent 无从插手——所以 extra_middleware 也明确不作用于它们(harness_profiles.py:699-705)。

7.2 声明式子 agent 的栈

每个声明式子 agent 都独立走一遍模型解析 + profile 查表graph.py:657-661)——子 agent 可以用和主 agent 不同的模型,那就该吃不同的 profile。

它的核心段比主 agent 短(graph.py:667-678):

Filesystem → Summarization → PatchToolCalls → Skills?

注意两点差异:

  • 没有 SubAgentMiddleware——子 agent 不能再派子 agent。
  • SkillsMiddleware 在末尾,而主 agent 里它排在第 1 位(graph.py:676-678 vs 818-819)。GP 子 agent 也是放末尾(graph.py:761-762)。这个位置不一致源码里没给理由。

然后同样是 _subagent_core_names 快照(graph.py:680)→ profile extra → prompt caching → 排除 → 用户中间件 → 再排除 → _ToolExclusionMiddlewaregraph.py:682-718)。和主 agent 是同一套三段结构

权限与中断的继承规则:子 agent 自己声明了 permissions 就整个替换父级的,否则继承(graph.py:664);interrupt_on 同理,且最后要和「permission 规则生成的 interrupt 项」合并,用户显式项按工具名胜出(graph.py:720-724_merge_fs_interrupt_ongraph.py:182-198)。工具默认继承父级,除非 spec 里明确写了 tools 键(graph.py:728)——用 "tools" in spec 判断而不是 spec.get("tools"),所以显式传空列表 = 真的没工具。

7.3 自动补的 general-purpose 子 agent

装配条件是两个「与」(graph.py:750-751):profile 没把它关掉(gp_profile.enabled is not False),且调用方没自己传一个叫 general-purpose 的。

这个顺序是刻意的——注释写得很清楚(graph.py:745-749):先处理调用方的子 agent,再决定要不要补默认的,这样既能让显式 spec 覆盖默认,又避免白白调用一遍基于工厂的 extra_middleware 然后把结果扔掉。

GP 栈和声明式子 agent 栈几乎一样,但用户中间件的继承规则特殊(graph.py:768-778):

_gp_original_name_to_index = {m.name: i for i, m in enumerate(gp_middleware)}
...
_gp_inheritable = [m for m in (middleware or []) if m.name in _gp_original_name_to_index]

只有「覆盖了 GP 默认槽位」的用户中间件才会被 GP 子 agent 继承,主 agent 特有的那些不带过去。快照在排除过滤之前取,所以即使某个槽位被 profile 排掉了,用户针对它的替换品仍然算「可继承」。

prompt 上还有个优先级特例:gp_profile.system_prompt 一旦设了,就压过 profile.base_system_prompt,profile 的 suffix 照样叠上去(graph.py:796-806)。理由是 GP 级配置比全局 override 更具体,用户两个都设时不该看到自己的 GP 覆盖被静默丢弃(harness_profiles.py:124-136)。

最后 GP 被 insert(0, ...) 塞到子 agent 列表最前(graph.py:814)。


8. 脚手架保护:三段式的排除校验

excluded_middleware 是把双刃剑——能删中间件就能把 agent 删残。Deep Agents 用三个函数把这件事框住,分工明确(都在 _excluded_middleware.py):

函数何时跑管什么位置
_validate_excluded_middleware_config装配开始时,每个 profile 一次不许排除必备脚手架_excluded_middleware.py:23-64
_apply_excluded_middleware每个栈、每栈两次真正过滤,并累计命中记录_excluded_middleware.py:90-165
_verify_excluded_middleware_coverage所有栈过滤完之后一次有没有哪条排除一个都没匹配上_excluded_middleware.py:168-225

8.1 必备脚手架是哪两个

_REQUIRED_MIDDLEWARE = (
(FilesystemMiddleware, ()),
(SubAgentMiddleware, ()),
)

定义在 graph.py:238-253,随后派生出 _REQUIRED_MIDDLEWARE_CLASSES_REQUIRED_MIDDLEWARE_NAMES 两个 frozenset 供快速判定。每项是「类 + 额外别名」的二元组,因为 .name 可以不等于 __name__

为什么是这两个:FilesystemMiddleware 撑着所有内置文件工具,还负责执行 permissions 规则(这是一条安全保证)SubAgentMiddleware 撑着 task 工具的 handler。删掉任何一个都是静默降级(graph.py:242-248)。

想真的去掉 task 工具?官方给的路子不是排除中间件,而是 general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False) 且不传同步子 agent——没东西可撑,task 自然不出现(graph.py:403-406)。

这条保护还做了两道闸HarnessProfile.__post_init__ 在构造时就拒(harness_profiles.py:762-770,经 _scaffolding_violation_labelharness_profiles.py:39-62),_validate_excluded_middleware_config 在装配时再拒一次。两边共用同一个报错文案生成器 _format_scaffolding_rejectionharness_profiles.py:65-79),所以用户在哪儿撞上看到的话术是一样的。

8.2 为什么命中要跨栈累计再校验

这是本节最不显然的设计。看 create_deep_agent 里那两个变量(graph.py:612-617):

_main_matched_classes: set[...] = set()
_main_matched_names: set[str] = set()

它们被传进 GP 子 agent 的两次过滤(graph.py:769-784主 agent 的两次过滤(graph.py:877-889),最后只在末尾校验一次(graph.py:903-909)。

原因:主 agent 和 GP 子 agent 用同一个 _profile,但两个栈的成分不同——GP 栈里根本没有 SubAgentMiddleware、没有 MemoryMiddleware、没有 HITL。一条排除项完全可能只在其中一个栈里有对应物。

profile.excluded_middleware = {"MemoryMiddleware"}

┌─────────────┴──────────────┐
▼ ▼
GP 子 agent 栈 主 agent 栈
(没有 Memory) (有 Memory)
命中 0 个 命中 1 个
│ │
└────────► 累计 ◄────────────┘


「至少在某处命中过」→ 通过

如果按栈逐个校验,这条合法的排除会在 GP 栈上误报「匹配不到」。所以规则定成只要在任意一个栈里命中过就算数,校验推迟到全部过滤跑完(_excluded_middleware.py:107-113)。

声明式子 agent 是另一回事:它们用的是自己的 _subagent_profile,所以各自带一对独立的累计集合,各自校验(graph.py:686-716)。

8.3 匹配语义与两个额外闸门

  • 类匹配用 type(mw) is cls,不是 isinstance_excluded_middleware.py:143)。这和 _merge_middleware 的「类即身份」语义一致:profile 排除基类时,用户自己派生的子类应当保留。
  • 字符串匹配 .name 精确相等。所以私有实现类可以靠公开别名被排除。
  • 一条字符串排除项在同一个栈里命中了多个不同类 → 直接 ValueError_raise_on_name_collisions_excluded_middleware.py:67-87)。多半是用户中间件的 .name 意外撞上了内置别名,逼你改用类形式消歧。
  • 一条都没命中 → ValueError,报错文案直说「typo 或过期 profile」,并建议尽量用类形式(导入时就能发现拼错)(_excluded_middleware.py:217-224)。

字符串排除项的语法在构造 profile 时就查:空串/纯空格、含 :(class-path 形式暂不支持)、下划线开头(私有类不在公开排除面)都当场抛 ValueError_validate_config_middleware_stringharness_profiles.py:866-909)。


9. 第六步:system prompt 的三段拼装

旧版这里有一套 SystemPromptConfig(prefix/base/suffix)+ _normalize_system_prompt + _assemble_prompt_parts 的四段式机器,且 base 默认是内置的 BASE_AGENT_PROMPT这套机制已在 0.7.x 整体移除BASE_AGENT_PROMPT 弃用(0.9.0 移除,只剩 graph.py:121-137__getattr__ 兼容垫片发警告),拼装换成了下面的三段式。

9.1 三个槽位

拼装逻辑就在 create_deep_agent 收尾处的十几行(graph.py:911-920):

base_prompt = _apply_profile_prompt(_profile, "")
if system_prompt is None:
final_system_prompt: str | SystemMessage = base_prompt
elif isinstance(system_prompt, SystemMessage):
... # 把 base_prompt 作为额外 text block 追加到调用方的 blocks 之后
else:
final_system_prompt = system_prompt + (f"\n\n{base_prompt}" if base_prompt else "")
取值缺省依据
① USER调用方传的 system_prompt(str 或 SystemMessagegraph.py:911-913
② BASE_profile.base_system_prompt(Deep Agents 不再自带默认提示词)_apply_profile_promptharness_profiles.py:780-799
③ SUFFIX_profile.system_prompt_suffix同上

参数 docstring 把顺序写得很清楚:USER -> BASE -> SUFFIX,段与段之间空行连接(graph.py:343-347);system_prompt=None 且 profile 两段都没配时,模型收到空的自撰提示(graph.py:349-351)。裸字符串默认放最前面,调用方的指令优先级最高。

9.2 传 SystemMessage 时如何保住 cache_control

create_deep_agent 的参数文档明说:传 SystemMessage原样保留其 content blocks 上已有的 cache_control 标记——这对手工打 Anthropic prompt-cache 断点有用;profile 拼出的 BASE/SUFFIX 作为一个新的 text block 追加在调用方的 blocks 之后(graph.py:353-357)。实现就是 graph.py:914-916 那句 SystemMessage(content_blocks=[*user_blocks, {"type": "text", "text": f"\n\n{base_prompt}"}])——不把各段 str() 拼成一个大字符串,缓存断点才不会丢。

9.3 主 agent 与子 agent 的不对称

这套三段装配只用于主 agent。子 agent(声明式和 GP)走的是另一个更简单的函数 _apply_profile_prompt(profile, base_prompt)harness_profiles.py:780-799):

prompt = profile.base_system_prompt if profile.base_system_prompt is not None else base_prompt
if profile.system_prompt_suffix is not None:
prompt = prompt + "\n\n" + profile.system_prompt_suffix

只有「替换 base + 追加 suffix」两个动作,签名是 str -> str,不支持 SystemMessage,因而子 agent 的 prompt 拿不到显式 cache_control 标记。调用点分别在 graph.py:740(声明式子 agent,base_prompt 是 spec 自己的 prompt)和 graph.py:806(GP,base_prompt 是 GP 默认 prompt);GP 还有 gp_profile.system_prompt 压过 profile.base_system_prompt 的特例(§7.3)。


10. 工具描述改写与工具排除

Profile 影响工具有两个互不重叠的机制。

10.1 改描述:构造期重写

_apply_tool_description_overrides(tools, overrides)_tools.py:29-65)在装配一开始就跑(graph.py:619-625),按工具名把描述换掉。三条规则:

工具形态处理依据
dict.copy() 后改 description_tools.py:56-60
BaseToolmodel_copy(update={"description": ...})_tools.py:61-63
裸 callable原样不动_tools.py:64

全程不修改调用方持有的对象——两条能改的路径都是先拷贝。裸 callable 改不了,因为安全替换描述得把它包成新的 tool 对象,代价太大(_tools.py:35-37)。

中间件注入的工具走另一条路:FilesystemMiddlewarecustom_tool_descriptions=_profile.tool_description_overridesgraph.py:820-826),SubAgentMiddleware 单独收 task_description=...get("task")graph.py:837)。

task 的描述改写有个明写的坑(harness_profiles.py:601-610):默认描述里含 {available_agents} 占位符,由 SubAgentMiddleware 在构建时替换成子 agent 名单。覆盖串里忘了带这个占位符,模型就看不到有哪些子 agent 可用。更普遍的问题是所有 override 都按工具名字符串匹配——工具改名或删掉,过期的 key 静默变 no-op,不报错。

10.2 删工具:运行期过滤

excluded_tools 不在构造期动工具列表,而是装一个 _ToolExclusionMiddleware 到栈尾(graph.py:892-893787-788717-718)。它在 wrap_model_call / awrap_model_call 里做一次列表推导,把名字在排除集里的工具从 request.tools 摘掉再交给下一层(middleware/_tool_exclusion.py:45-65)。

为什么必须运行期、必须最后,§6.2 已经说过:只有这样才能同时覆盖用户工具和中间件注入的工具,且不给后来的 wrap_model_call 留下把工具加回去的机会。

对照记:tools= 参数永远是加法graph.py:331-339),要减只能靠 profile 的 excluded_tools


11. 弃用机制与 model=None 的过渡

_api/deprecation.py 是对 langchain_core 私有弃用工具的一层适配器,集中 import 面,上游改名只需改一个文件。

它做的唯一实质性修补在 warn_deprecateddeprecation.py:39-97):上游那个函数把 stacklevel=4 写死了,那个值是为「装饰器包裹」的帧布局准备的;直接在函数体里调用时,警告会被归到用户调用点之上一帧。这里的做法是先 catch_warnings(record=True) 把上游格式化好的警告捕获下来,再用显式 stacklevel 重新发一次。

model=None 的过渡则是「两个入口、一次警告」的小设计:

用户直接调 get_default_model() create_deep_agent(model=None)
│ │
▼ ▼
@deprecated 装饰器(每进程一次) warn_deprecated(...) 参数级警告
│ │
└────────────► _build_default_model() ◄──┘
(无装饰器,纯构造)

get_default_model@deprecatedgraph.py:151-179),_build_default_model 不带(graph.py:140-148)。create_deep_agent 走后者,注释写明理由:不烧掉 get_default_model 的去重标志位,直接调它的用户仍能看到自己那一次警告graph.py:600-601)。

弃用内容本身:model=None0.5.3 弃用,1.0.0 移除,届时参数类型从 BaseChatModel | str | None 收紧成 BaseChatModel | strgraph.py:304-313586-599)。默认模型是 claude-sonnet-4-6

配套还有 reset_deprecation_dedupedeprecation.py:100-131)——@deprecated 的「每进程只警告一次」靠闭包里一个 warned 变量,测试要逐用例断言就得把它复位,否则 pytest -n auto 下断言会变得依赖执行顺序。


12. 巧妙之处(可以直接抄的)

① 用「核心段快照」定义插入点,而不是用固定下标。 _main_core_names 抓的是名字集合,尾部段之后再算 max(index) + 1graph.py:855201-235)。尾部段将来增删多少个中间件,插入点都不会错位。

② 排除项跨栈累计、最后统一校验。 同一个 profile 作用于成分不同的多个栈,逐栈校验必然误报;累计后校验既能抓 typo,又不冤枉合法配置(_excluded_middleware.py:107-113graph.py:612-617)。

③ 「类即身份」贯穿始终。 _merge_middleware 按类原地替换、_apply_excluded_middlewaretype() is 精确匹配(不用 isinstance)——用户派生的子类不会被针对基类的排除误伤(harness_profiles.py:1116-1176_excluded_middleware.py:99-103)。

④ frozen dataclass 补 __post_init__ 冻容器。 frozen=True 只挡属性重绑,挡不住改 dict/list 内容。两个 profile 类都把 mapping 包成 MappingProxyType、把序列冻成 tuple,让「注册后偷改」从静默生效变成 TypeErrorharness_profiles.py:724-770provider_profiles.py:132-163)。

⑤ bootstrap 失败要原地回滚。 _PROVIDER_PROFILES.clear() + update(saved) 而不是重新赋值——别的模块持有的是同一个 dict 对象的引用(_builtin_profiles.py:161-166)。

⑥ 内置走 import、第三方走 entry point。 环境里一个坏掉的 dist-info 不该让 SDK 自己的默认值失效;第三方插件炸了只记日志跳过(_builtin_profiles.py:1-20179-235)。

⑦ 先处理调用方子 agent,再决定补不补默认的。 顺带避免了「白白调用一遍工厂型 extra_middleware 然后丢弃」(graph.py:745-749)。

SystemMessage 直传时 profile 段追加为新 block。 调用方 blocks 上的 cache_control 标记原样保留,profile 的 BASE/SUFFIX 作为额外 text block 追加(graph.py:914-916),缓存断点不丢。


13. 边界与局限

  • 文档与代码有一处对不上。 middleware 参数 docstring 把 _ToolExclusionMiddleware 排在尾部段第 2 位(graph.py:379-391),实际代码 append 在栈最末(graph.py:892-893)。
  • 子 agent 的 prompt 拿不到 cache_control _apply_profile_prompt 签名是 str -> strharness_profiles.py:780-799),SystemMessage 形态的装配只服务主 agent。
  • SkillsMiddleware 的位置不一致。 主 agent 排第 1(graph.py:818-819),子 agent 和 GP 都排在核心段末尾(graph.py:676-678761-762)。源码没给理由。
  • 裸 callable 工具的描述改不了_tools.py:35-3764)。
  • 描述覆盖按字符串匹配,过期 key 静默失效harness_profiles.py:596-599)。
  • state_schema 必须是 DeepAgentState 子类这条约束只靠类型标注——TypedDict 不支持 issubclass,运行期不校验(graph.py:580-582)。
  • HarnessProfileConfigexcluded_middleware 只收字符串名,class-path (module:Class) 形式明确不支持、遇到就抛(harness_profiles.py:897-901)。运行时排除私有类只能靠 serialized_name 别名。
  • HarnessProfileHarnessProfileConfig 不可逆:带 extra_middleware 的 profile 导出直接 ValueError,属于设计上不支持(harness_profiles.py:460-467)。
  • 插件顺序不保证。 两个第三方插件注册同一个 key,谁在上取决于 entry_points 的返回顺序(_builtin_profiles.py:199-203)。

14. 横向对比

本章讲的是「构造期把 harness 拼出来」这件事,和同架其它章的关系:


15. 代码地图(导航索引)

路径同本章开头的引用约定,均相对包目录 libs/deepagents/deepagents/

主题文件符号
装配主入口(六步全在这)graph.pycreate_deep_agent
默认状态 schema(DeltaChannel)graph.pyDeepAgentState
已弃用的内置 base promptgraph.py_LEGACY_BASE_AGENT_PROMPT(经 __getattr__ 兼容)
prompt 三段拼装graph.pycreate_deep_agent 收尾段 + harness_profiles.py_apply_profile_prompt
自定义中间件合并规则graph.py_apply_custom_middleware
必备脚手架清单graph.py_REQUIRED_MIDDLEWARE_REQUIRED_MIDDLEWARE_NAMES
prompt caching 装配(Anthropic 无条件 + Bedrock/Fireworks 软依赖)middleware/_prompt_caching.pyappend_prompt_caching_middleware_create_bedrock_prompt_caching_middleware_create_fireworks_prompt_caching_middleware
权限生成的 interrupt 与用户配置合并graph.py_merge_fs_interrupt_on
默认模型构造(无警告版)graph.py_build_default_modelget_default_model
模型字符串解析_models.pyresolve_model
模型标识/提供方反射_models.pyget_model_identifierget_model_provider
provider 名归一化_models.py_normalize_provider
工具描述改写_tools.py_apply_tool_description_overrides
排除项配置校验_excluded_middleware.py_validate_excluded_middleware_config
排除项应用与命中记录_excluded_middleware.py_apply_excluded_middleware
排除项覆盖率校验_excluded_middleware.py_verify_excluded_middleware_coverage
同名撞多类的拒绝_excluded_middleware.py_raise_on_name_collisions
harness profile 运行时类型profiles/harness/harness_profiles.pyHarnessProfile
harness profile 声明式类型profiles/harness/harness_profiles.pyHarnessProfileConfig
GP 子 agent 子配置profiles/harness/harness_profiles.pyGeneralPurposeSubagentProfile
profile 注册(additive)profiles/harness/harness_profiles.pyregister_harness_profile_register_harness_profile_impl
profile 两级查表profiles/harness/harness_profiles.py_get_harness_profile
按模型实例反查 profileprofiles/harness/harness_profiles.py_harness_profile_for_model
profile 逐字段合并profiles/harness/harness_profiles.py_merge_profiles_merge_middleware
extra_middleware 物化profiles/harness/harness_profiles.pymaterialize_extra_middleware
子 agent 的 prompt 叠加profiles/harness/harness_profiles.py_apply_profile_prompt
排除项字符串语法检查profiles/harness/harness_profiles.py_validate_config_middleware_string
provider profile 类型profiles/provider/provider_profiles.pyProviderProfile
provider profile 组装 kwargsprofiles/provider/provider_profiles.pyapply_provider_profile
provider profile 合并(链式 hook)profiles/provider/provider_profiles.py_merge_provider_profiles
profile key 语法校验profiles/_keys.pyvalidate_profile_key
懒加载 bootstrapprofiles/_builtin_profiles.py_ensure_builtin_profiles_loaded
entry-point 插件调用profiles/_builtin_profiles.py_invoke_profile_plugins
内置 OpenAI provider profileprofiles/provider/_openai.pyregister
内置 OpenRouter 版本闸profiles/provider/_openrouter.pycheck_openrouter_version
内置 Codex harness profileprofiles/harness/_openai_codex.py_CODEX_MODEL_SPECSregister
内置 Nemotron harness profileprofiles/harness/_nvidia_nemotron_3_ultra.py_build_extra_middlewareregister
工具排除中间件middleware/_tool_exclusion.py_ToolExclusionMiddleware
summarization 的公开别名middleware/summarization.pyserialized_name
GP 子 agent 基础 specmiddleware/subagents.pyGENERAL_PURPOSE_SUBAGENT
弃用适配层_api/deprecation.pywarn_deprecatedreset_deprecation_dedupe