数据截至 (上游 commit 25aa2735dabb)
装配流水线:一次 create_deep_agent 到底做了什么
30 秒导读:
create_deep_agent()本身不跑任何 agent 逻辑,它只做一件事——把「模型 + 中间件栈 + 子 agent 规格 + system prompt」这四样东西按固定规则组装好,然后调 LangChain 的create_agent编译成图。本章只讲这条构造期的流水线:谁先谁后、顺序为什么这么排、用户怎么插手、以及 profile 这套「按模型改行为」的旋钮长什么样。各中间件跑起来干什么,是 03–06 的事。
引用约定: 本章所有
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 变成 BaseChatModel | resolve_model | _models.py:35-57 |
| ② 查 profile | 按模型找 harness 行为配置 | _harness_profile_for_model | profiles/harness/harness_profiles.py:1252-1322 |
| ③ 定后端 | 文件与执行落在哪 | StateBackend | graph.py:627 |
| ④ 子 agent 规格 | 三种形态分流 + 补默认 | 内联循环、GENERAL_PURPOSE_SUBAGENT | graph.py:645-814 |
| ⑤ 主中间件栈 | 三段拼 + 过滤 | _apply_custom_middleware | graph.py:816-909 |
| ⑥ prompt 拼装 | 三段合一 | _apply_profile_prompt | graph.py:911-920 |
| 收尾 | 编译成图 | create_agent | graph.py:922-944 |
收尾那一下还顺手盖了两个章:recursion_limit=9_999(把 LangGraph 默认的递归上限抬到近乎不限),以及 ls_integration: "deepagents" 之类的追踪元数据(graph.py:935-943)。
状态 schema 默认是 DeepAgentState(graph.py:70-73)——它在 messages 上挂了 DeltaChannel,把 checkpoint 的增长从 O(N²) 压到 O(N)。这条属于上下文工程,细节见 05-context-engineering。
3. 第一步:模型是怎么来的
3.1 三条入口
model 参数接受 三种东西,走三条路(graph.py:584-604):
| 传入 | 处理 | 依据 |
|---|---|---|
BaseChatModel 实例 | 原样使用,_model_spec = None | graph.py:584、_models.py:54-55 |
"provider:model" 字符串 | resolve_model 走 init_chat_model,_model_spec 记住原串 | graph.py:604 |
None | 发弃用警告 + 造默认 claude-sonnet-4-6 | graph.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_profile(profiles/provider/provider_profiles.py:318-380)负责把注册过的 ProviderProfile 翻译成 init_chat_model 的 kwargs。一个 ProviderProfile 有三个字段(provider_profiles.py:79-130):
| 字段 | 干什么 | 典型用途 |
|---|---|---|
init_kwargs | 静态 kwargs | use_responses_api=True |
pre_init | 构造前的副作用回调,抛异常就中止构造 | 最低版本检查 |
init_kwargs_factory | 每次解析时现算的 kwargs | 读环境变量、造新的 header 字典 |
优先级是 init_kwargs < init_kwargs_factory() < 调用方显式 kwargs(provider_profiles.py:376-379)。
内置的三个 provider profile 正好演示了三种用法:
openai→init_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)。openrouter→pre_init挂check_openrouter_version,装的langchain-openrouter低于0.2.0就直接抛ImportError(profiles/provider/_openrouter.py:89-128)。
3.3 和 harness profile 的分工
这是两套长得很像、职责完全不同的注册表,一句话切分:
| ProviderProfile | HarnessProfile | |
|---|---|---|
| 管什么 | 模型怎么造 | 模型造好之后 harness 怎么跑 |
| 消费者 | resolve_model | create_deep_agent |
| 典型字段 | init_kwargs / pre_init | system_prompt_suffix / excluded_tools / extra_middleware |
| 注册表 | _PROVIDER_PROFILES | _HARNESS_PROFILES |
| 位置 | profiles/provider/provider_profiles.py:37-163 | profiles/harness/harness_profiles.py:483-779 |
两者共用同一套 key 语法(provider 或 provider:model)和同一个校验函数 validate_profile_key(profiles/_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 两个配套类型
HarnessProfileConfig(harness_profiles.py:192-480)是HarnessProfile的「能写进 YAML/JSON 的子集」:只有纯字符串/布尔/列表/嵌套 dict,没有extra_middleware,excluded_middleware也只收字符串名。注册时会自动to_harness_profile()转成运行时形态。反向的from_harness_profile遇到extra_middleware直接抛ValueError而不是静默丢弃(harness_profiles.py:460-467)——宁可报错也不悄悄降级。GeneralPurposeSubagentProfile(harness_profiles.py:83-189)只管那个自动补的general-purpose子 agent,三个字段:enabled/description/system_prompt。enabled是三态(None= 继承或默认开、True= 强制开、False= 关)——三态是为了让模型级 profile 能把 provider 级 profile 关掉的东西再打开(harness_profiles.py:97-111、1178-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;注册表里只有内置默认,那就打 DEBUG(harness_profiles.py:1308-1321,配合 _has_any_harness_profile,harness_profiles.py:1028-1046)。这条专治「我注册的 profile 怎么不生效」。
_get_harness_profile(harness_profiles.py:1047-1101)本身是两级查表:
- 精确 key(
anthropic:claude-sonnet-4-6) - provider 前缀(
anthropic) - 两个都在就
_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_profiles(harness_profiles.py:1194-1250)按字段各定各的规则:
| 字段类型 | 合并规则 | 例子 |
|---|---|---|
| 单值(两个 prompt 字段) | override 设了就用 override,否则回落 base | provider 设 suffix、模型级留 None → 保留 provider 的 |
| 描述映射 | 逐 key 合并,override 赢 | provider 改 task、模型级改 ls → 两个都在 |
| 两个排除集合 | 并集 | {"execute"} ∪ {"grep"} → 两个都排除 |
extra_middleware | 按具体类当身份合并 | 同类原地替换,新类追加到末尾 |
general_purpose_subagent | 逐字段合并 | 模型级 enabled=True 能推翻 provider 级的 False |
_merge_middleware(harness_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-100、133-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-32、81-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 对象随后被塞给 FilesystemMiddleware、SkillsMiddleware、MemoryMiddleware、SubAgentMiddleware 和 summarization 中间件——注意主 agent 和所有子 agent 共享同一个 backend 实例(graph.py:668-672、753-757、818-819、829-830、843、864-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 栈的确切顺序(按代码,不按文档)
| # | 中间件 | 何时存在 | 依据 |
|---|---|---|---|
| 1 | SkillsMiddleware | skills is not None | graph.py:818-819 |
| 2 | FilesystemMiddleware | 总是(必备脚手架) | graph.py:820-826 |
| 3 | SubAgentMiddleware | inline_subagents 非空 | graph.py:827-840 |
| 4 | SummarizationMiddleware(别名) | 总是 | graph.py:841-846 |
| 5 | PatchToolCallsMiddleware | 总是 | graph.py:841-846 |
| 6 | AsyncSubAgentMiddleware | 有 async 子 agent | graph.py:848-851 |
| — | ← 用户中间件插点 | 传了 middleware= 且是新名字 | graph.py:855、883 |
| 7 | profile 的 extra_middleware | profile 配了 | graph.py:859 |
| 8 | AnthropicPromptCachingMiddleware | 总是(非 Anthropic 模型自动 no-op) | middleware/_prompt_caching.py:43、graph.py:860 |
| 9 | BedrockPromptCachingMiddleware | 装了 langchain-aws | middleware/_prompt_caching.py:13-24 |
| 10 | FireworksPromptCachingMiddleware | 装了 langchain-fireworks | middleware/_prompt_caching.py:27-38 |
| 11 | MemoryMiddleware | memory is not None | graph.py:861-870 |
| 12 | HumanInTheLoopMiddleware | 有 interrupt 配置 | graph.py:871-876 |
| 13 | _ToolExclusionMiddleware | profile.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-22、32-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_middleware(graph.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_id | AsyncSubAgent(远程后台任务 ) | async_subagents,交给 AsyncSubAgentMiddleware |
有 runnable | CompiledSubAgent(已编译好) | 原样进 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-678vs818-819)。GP 子 agent 也是放末尾(graph.py:761-762)。这个位置不一致源码里没给理由。
然后同样是 _subagent_core_names 快照(graph.py:680)→ profile extra → prompt caching → 排除 → 用户中间件 → 再排除 → _ToolExclusionMiddleware(graph.py:682-718)。和主 agent 是同一套三段结构。
权限与中断的继承规则:子 agent 自己声明了 permissions 就整个替换父级的,否则继承(graph.py:664);interrupt_on 同理,且最后要和「permission 规则生成的 interrupt 项」合并,用户显式项按工具名胜出(graph.py:720-724、_merge_fs_interrupt_on 在 graph.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_label,harness_profiles.py:39-62),_validate_excluded_middleware_config 在装配时再拒一次。两边共用同一个报错文案生成器 _format_scaffolding_rejection(harness_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_string,harness_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 或 SystemMessage) | 无 | graph.py:911-913 |
| ② BASE | _profile.base_system_prompt | 空(Deep Agents 不再自带默 认提示词) | _apply_profile_prompt(harness_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 影响工具有两个互不重叠的机制。