1. 本章目标 #
前面十章每一章都在往智能体上加东西:技能、记忆、摘要、子智能体、审批。这些东西都是 middleware,它们堆在同一个栈里、按固定顺序执行。
这一章把那个栈完整拆开。这不是为了满足好奇心——顺序决定行为。第 45 章说过 Memory 要放在提示缓存之后,第 46 章说过摘要在提示缓存之前,这些结论都来自装配顺序。不理解顺序,定制 middleware 时踩的坑会非常难查。
学完你应能:
- 说出完整的装配顺序,以及用户 middleware 插在哪一格(§2)
- 用
.name同名替换默认实例,并知道为什么继承一个 middleware 通常不会替换它(§3) - 理解为什么
FilesystemMiddleware和SubAgentMiddleware不可移除(§4) - 用
HarnessProfile做按模型的默认值,并且让 key 真的匹配上(§5、§6) - 掌握 profile 七个字段的实际效果,包括
{available_agents}这个必填占位符(§7) - 用
response_format拿结构化输出(§8)
前置依赖: 第 10 章(Middleware 基础)、第 42~48 章(各个 middleware 的功能)。第 46 章 §3 提过装配顺序,这一章是精确版。
参考文档:
建议阅读顺序: §6 是本章最重要的一节——profile 不生效是个高频问题,而原因藏在一个很不直观的地方。§2 的顺序表建议收藏。
本章验证环境:deepagents 0.7.12。全部实验零 token。本章的观察手段是拦截 create_agent 调用,把实际传进去的 middleware 列表打出来——这比读文档准确。
2. 装配顺序 #
2.1. 默认只有五个 #
"""拦截 create_agent,看实际装了什么。"""
import deepagents.graph as G
CAP = {}
_orig = G.create_agent
def spy_create_agent(model, **kw):
CAP["middleware"] = [m.name for m in kw.get("middleware", [])]
CAP["system_prompt"] = kw.get("system_prompt")
return _orig(model, **kw)
G.create_agent = spy_create_agent
create_deep_agent(model=F(), backend=StateBackend())
print(CAP["middleware"])默认(什么都不传)(5 个):
1. FilesystemMiddleware
2. SubAgentMiddleware
3. SummarizationMiddleware
4. PatchToolCallsMiddleware
5. AnthropicPromptCachingMiddleware两个值得注意的点:
SubAgentMiddleware默认就在,因为默认会自动加一个general-purpose子智能体(第 47 章 §9)AnthropicPromptCachingMiddleware无条件装,即使模型完全不是 Anthropic 的(第 46 章 §8 验证过它对非 Anthropic 模型是 no-op)
2.2. 各可选项插在哪 #
一项一项加,看位置:
+ skills: 1. SkillsMiddleware ← 插到最前面
2. FilesystemMiddleware
...
+ memory: ...
5. AnthropicPromptCachingMiddleware
6. MemoryMiddleware ← 提示缓存之后
+ interrupt_on: ...
5. AnthropicPromptCachingMiddleware
6. HumanInTheLoopMiddleware ← 最后全部打开:
全量 (8 个):
1. SkillsMiddleware
2. FilesystemMiddleware
3. SubAgentMiddleware
4. SummarizationMiddleware
5. PatchToolCallsMiddleware
6. AnthropicPromptCachingMiddleware
7. MemoryMiddleware
8. HumanInTheLoopMiddleware2.3. 完整顺序表 #
把所有可能的位置列全(方括号表示条件装载):
| # | Middleware | 条件 | 归属 |
|---|---|---|---|
| 1 | SkillsMiddleware |
传了 skills= |
核心栈 |
| 2 | FilesystemMiddleware |
总是 | 核心栈(不可移除) |
| 3 | SubAgentMiddleware |
有同步子智能体(含默认 GP) | 核心栈(不可移除) |
| 4 | SummarizationMiddleware |
总是 | 核心栈 |
| 5 | PatchToolCallsMiddleware |
总是 | 核心栈 |
| 6 | AsyncSubAgentMiddleware |
传了异步子智能体 | 核心栈 |
| — | 你的 middleware | 传了 middleware= |
插在这里 |
| 7 | profile 的 extra_middleware |
profile 里配了 | 尾部 |
| 8 | AnthropicPromptCachingMiddleware |
无条件 | 尾部 |
| 9 | BedrockPromptCachingMiddleware |
装了 langchain-aws |
尾部 |
| 10 | FireworksPromptCachingMiddleware |
装了 langchain-fireworks |
尾部 |
| 11 | MemoryMiddleware |
传了 memory= |
尾部 |
| 12 | HumanInTheLoopMiddleware |
有 interrupt_on= 或 interrupt 权限规则 |
尾部 |
| 13 | _ToolExclusionMiddleware |
profile 有 excluded_tools |
最后 |
实测用户 middleware 的位置:
1. SkillsMiddleware
2. FilesystemMiddleware
3. SubAgentMiddleware
4. SummarizationMiddleware
5. PatchToolCallsMiddleware
6. MyMw ← 在这里
7. AnthropicPromptCachingMiddleware
8. MemoryMiddleware
9. HumanInTheLoopMiddleware传多个时按传入顺序排列,而且在 profile 的 extra_middleware 之前:
5. PatchToolCallsMiddleware
6. UserMw ← 用户的在前
7. ProfileMw ← profile 的在后
8. AnthropicPromptCachingMiddleware2.4. 为什么 Memory 在提示缓存之后 #
这个顺序不是随便定的。源码里有注释:
Harness-profile middleware goes between core middleware and memory so
that memory updates (which change the system prompt) don't invalidate the
Anthropic prompt cache prefix.逻辑是这样的:
- 提示缓存的原理是给系统提示词的前缀打断点,缓存这个前缀
- Memory 的内容会变(智能体会自己更新记忆,第 45 章 §6)
- 如果 Memory 在缓存断点之前,那记忆一改、缓存前缀就变了,整份缓存失效
- 放在断点之后,记忆怎么改都不影响前缀
这就是第 45 章那个 add_cache_control=True 的配套设计。它解释了为什么装配顺序不可随意调整。
3. 同名替换:一个反直觉的坑 #
要定制某个默认 middleware,办法是传一个同名的实例进去替换它。规则很简单:
.name匹配栈里已有的名字就原地替换(保持位置),否则追加。
问题在于「同名」的判定比想象的严格。
3.1. 继承通常不会替换 #
"""继承 FilesystemMiddleware,但不改 name。"""
class MyFs(FilesystemMiddleware):
pass
print(MyFs(backend=StateBackend()).name)子类不改 name: .name='MyFs'
栈 (6): ['FilesystemMiddleware', 'SubAgentMiddleware', 'SummarizationMiddleware',
'PatchToolCallsMiddleware', 'MyFs', 'AnthropicPromptCachingMiddleware']
是替换还是追加: 追加变成 6 个了。 因为 .name 默认是类名,继承之后名字变成了 MyFs,跟 FilesystemMiddleware 不匹配,所以走了「追加」分支。
结果是栈里同时有两个文件系统 middleware——默认那个还在,你的那个也在。这种状态很难查,因为行为看起来「大部分正常,偶尔奇怪」。
3.2. 必须显式对齐名字 #
"""正确的替换写法。"""
class MyFs(FilesystemMiddleware):
name = "FilesystemMiddleware" # 关键:显式对齐子类显式对齐 name: .name='FilesystemMiddleware'
栈 (5): ['FilesystemMiddleware', 'SubAgentMiddleware', 'SummarizationMiddleware',
'PatchToolCallsMiddleware', 'AnthropicPromptCachingMiddleware']
是替换还是追加: 替换还是 5 个,位置也没变(第 1 位)。这才是替换。
所以定制默认 middleware 的检查清单是:
- 写好子类,显式设
name等于要替换的那个 - 传进
middleware= - 数一下栈的长度有没有变——变了就说明是追加不是替换
3.3. 直接传实例更省事 #
大部分情况下你不需要继承——FilesystemMiddleware 等类本身就接受参数,直接传配置好的实例即可:
"""不继承,直接传配好参数的实例。"""
agent = create_deep_agent(
model=...,
middleware=[
# 只暴露三个只读文件工具
FilesystemMiddleware(backend=StateBackend(),
tools=["ls", "read_file", "grep"]),
],
)这样 .name 天然就是 FilesystemMiddleware,替换必然成功。
profile 的 extra_middleware 也能这样替换:
ProfileMw 出现 1 次 -> 原地替换4. 两个不可移除的 middleware #
HarnessProfile.excluded_middleware 可以从栈里删东西,但有两个删不掉:
_REQUIRED_MIDDLEWARE = (
(FilesystemMiddleware, ()),
(SubAgentMiddleware, ()),
)尝试排除会在构造 HarnessProfile 时就报错(不是等到 create_deep_agent):
HarnessProfile(excluded_middleware=[FilesystemMiddleware])ValueError: HarnessProfile.excluded_middleware is invalid:
- required scaffolding cannot be excluded: FilesystemMiddleware (back filesystem
tools, subagent dispatch, and permission enforcement — use excluded_tools for
per-tool visibility or adjust profile settings instead of stripping scaffolding)类和字符串两种写法都拦:
排除 FilesystemMiddleware(类) 构造 Profile 就报错
排除 'FilesystemMiddleware'(串) 构造 Profile 就报错
排除 SubAgentMiddleware 构造 Profile 就报错4.1. 为什么 #
源码注释说得很清楚:
Removing any of these silently breaks core features: `FilesystemMiddleware` backs
every built-in file tool and now also enforces `permissions` rules (a security
guarantee), while `SubAgentMiddleware` backs the `task` tool handler.
Tracked here so `HarnessProfile.excluded_middleware` cannot strip them:
`_apply_excluded_middleware` raises `ValueError` rather than proceeding with
a silently degraded agent.FilesystemMiddleware 的第二个职责是关键:它执行 permissions 规则(第 43 章)。移除它不只是文件工具没了——所有权限管控都失效了。这是安全问题,所以框架选择「响亮地失败」而不是静默降级。
这跟第 43 章 §6.3 那个 NotImplementedError(权限与沙箱互斥)是同一种设计取向。
4.2. 可移除的三个 #
排除 SummarizationMiddleware 剩 4: [Filesystem, SubAgent, PatchToolCalls, PromptCaching]
排除 PatchToolCallsMiddleware 剩 4: [Filesystem, SubAgent, Summarization, PromptCaching]
排除 AnthropicPromptCachingMiddleware 剩 4: [Filesystem, SubAgent, Summarization, PatchToolCalls]
排除 三个一起 剩 2: [FilesystemMiddleware, SubAgentMiddleware]三个全排掉只剩两个必需的。 这是能达到的最小栈。
什么时候真要这么做?
- 排除
SummarizationMiddleware:你想自己完全控制上下文压缩策略 - 排除
AnthropicPromptCachingMiddleware:确定不用 Anthropic 且想省掉一层包装(其实它是 no-op,收益很小) - 排除
PatchToolCallsMiddleware:一般不要,它负责修复模型发出的畸形工具调用
4.3. 排除名字写错会报错 #
排除 不存在的名字 ValueError: HarnessProfile.excluded_middleware entries matched no
middleware across any assembled stack: 'NoSuchMiddleware' (string).
Typo or stale profile — every exclusion must correspond ...排除项必须命中,否则报错。 设计意图是「排除了一个不存在的名字,几乎肯定是打错字或者 profile 过期了」。
私有名字也拦:
排除 私有名字 ValueError: `excluded_middleware` entry '_ToolExclusionMiddleware'
cannot start with '_' (underscore-prefixed names refer to private
middleware classes not part of the public exclusion surface).注意这和 excluded_tools 的行为不一样——那个排除不存在的工具名不会报错(§7.3)。
5. HarnessProfile 的七个字段 #
base_system_prompt str | None
system_prompt_suffix str | None
tool_description_overrides Mapping[str, str]
excluded_tools frozenset[str]
excluded_middleware frozenset[type[AgentMiddleware] | str]
extra_middleware Sequence[AgentMiddleware] | Callable[[], Sequence[...]]
general_purpose_subagent GeneralPurposeSubagentProfile | None还有个 HarnessProfileConfig,字段少一个 extra_middleware——它是纯数据版,用于从配置文件加载(不能带 middleware 实例)。
profile 的用途是按模型设默认值。同一份业务代码跑在不同模型上时,用 profile 把「这个模型需要什么特殊配置」隔离出来,而不是散落在业务逻辑里。
6. 让 profile 真的生效 #
这是本章最容易踩的坑。 profile 注册了却不生效是个高频问题,原因在 key 的匹配规则。
6.1. key 怎么匹配 #
分两条路径:
传字符串模型规格时,字符串本身就是查表用的 key:
agent = create_deep_agent(model="deepseek:deepseek-v4-flash")
# 查表用 "deepseek:deepseek-v4-flash"查表有回退:
查 faker:big-model -> '[精确 key 的 profile]'
查 faker:other-model -> '[provider 级 profile]' ← 回退到 provider
查 faker -> '[provider 级 profile]'
查 unknown:x -> None精确 key 优先,找不到就回退到 provider 前缀。 所以你可以注册一个 "deepseek" 级的 profile 覆盖该家所有模型,再给个别模型注册精确 key 覆盖。
传预构建模型实例时,规则复杂得多:
identifier = get_model_identifier(model) # 来自 model_dump(通常是 model_name)
provider = get_model_provider(model) # 来自 _get_ls_params依次尝试:
f"{provider}:{identifier}"(仅当两者都有、且 identifier 不含:)identifier(仅当它本身含:)provider
第 2 步那个限制是故意的,源码注释解释了原因:
A *bare* identifier (no `:`) is deliberately not consulted against the registry.
If it were, a pre-built model whose `model_name` happened to coincide with a
registered provider key (e.g. an in-house proxy whose identifier is `"openai"`)
would silently pick up that provider's profile.6.2. provider 是类名的小写 #
这是我调试半天才发现的:
class Plain(BaseChatModel):
@property
def _llm_type(self) -> str:
return "myllm" # 这个值不是 provider!
class Named(BaseChatModel):
model_name: str = "my-model-v1"
@property
def _llm_type(self) -> str:
return "myllm"Plain identifier=None provider='plain'
-> 会依次尝试的 key: ['plain']
Named identifier='my-model-v1' provider='named'
-> 会依次尝试的 key: ['named:my-model-v1', 'named']对预构建实例,
provider是模型类名的小写,跟_llm_type的返回值无关。
这个坑在写测试和自定义模型包装时特别容易撞上——换个子类名,key 就变了。用真实的模型类(ChatAnthropic → chatanthropic?)时也要实测确认,不要猜。
6.3. 没匹配上会有警告 #
框架会提醒你:
No harness profile matched pre-built model F (identifier=None, provider='f');
using defaults. If you registered a profile for this model, ensure the key matches
the model's resolved provider and identifier.但只有在注册表非空时才是 WARNING 级别(空注册表时降为 DEBUG,因为那种情况下没匹配是正常的)。所以如果你没看到这条警告,检查一下日志级别有没有把 WARNING 过滤掉。
最稳妥的做法:
"""确认 key 到底该写什么。"""
from deepagents._models import get_model_identifier, get_model_provider
m = YourModel(...)
print(get_model_provider(m), get_model_identifier(m))或者干脆用字符串模型规格——那条路径的 key 就是你写的字符串,没有猜的空间。
6.4. 重复注册是合并,不是覆盖 #
register_harness_profile("mykey", HarnessProfile(base_system_prompt="第一次"))
register_harness_profile("mykey", HarnessProfile(base_system_prompt="第二次"))第二次注册走的是 _merge_profiles(existing, profile):标量字段后者覆盖,集合类字段取并集。
这带来一个真实的报错:
register_harness_profile("k", HarnessProfile(excluded_middleware=["A"]))
register_harness_profile("k", HarnessProfile(excluded_middleware=["B"]))TypeError: unsupported operand type(s) for |: 'list' and 'frozenset'合并时执行 base.excluded_middleware | override.excluded_middleware,而 list 不支持 |。
两条结论:
excluded_middleware和excluded_tools传frozenset或set,别传 list(单次注册时 list 能用,重复注册就炸)- 同一个 key 别重复注册。要改就重新起进程,或者直接操作注册表字典
7. 七个字段的实际效果 #
下面的实验都用同一个模型类(避免 §6.2 那个坑),key 就是类名小写。
7.1. 提示词组装:USER → BASE → SUFFIX #
只有 BASE '[BASE]'
USER + BASE '[USER]\n\n[BASE]'
USER + BASE + SUFFIX '[USER]\n\n[BASE]\n\n[SUFFIX]'
USER + 只有 SUFFIX '[USER]\n\n[SUFFIX]'顺序固定是 USER → BASE → SUFFIX,用空行分隔。三者都可以缺省。
用途分工:
system_prompt=(USER):业务指令,每个 agent 不同base_system_prompt(BASE):这个模型需要的通用指令,比如某些模型需要更明确的格式要求system_prompt_suffix(SUFFIX):放在最后的强调,比如安全提醒
传 SystemMessage 而不是字符串时,profile 的内容会作为额外的 text block 追加,保留原有的 cache_control 标记——这对显式控制 Anthropic 缓存断点有用。
7.2. tool_description_overrides #
内置工具的描述可以整体替换:
HarnessProfile(tool_description_overrides={
"write_file": "【定制】把内容写进文件。路径必须以 /reports/ 开头。",
"delete": "【定制】删除文件。这个操作不可撤销,务必先确认。",
})默认 write_file 描述 (321 字符):
'Writes content to a file. Creates the file if it does not exist; replaces...'
覆盖后:
write_file: '【定制】把内容写进文件。路径必须以 /reports/ 开头。'
delete: '【定制】删除文件。这个操作不可撤销,务必先确认。'
未覆盖的 read_file 仍是默认 (1220 字符)是整体替换,不是追加。 没覆盖的工具保持默认。
这是第 46 章 §4 那个「schema 开销」的另一个优化点——read_file 的默认描述有 1220 字符,如果你的场景不需要它的全部能力,换一段短的能省不少。
7.3. task 的覆盖必须带 {available_agents} #
这一条单独说,因为漏了会静默出问题。
默认 task 描述 (1432 字符):
'Launch an ephemeral subagent to handle a complex, multi-step task.
Available agent types and the tools they have access to:
- general-purpose: ...
- writer: 写手
...'覆盖时必须自己带上占位符:
HarnessProfile(tool_description_overrides={
"task": "【定制】把活派给下属。\n\n可用下属:\n{available_agents}\n\n注意:一次只派一个。",
})'【定制】把活派给下属。
可用下属:
- general-purpose: General-purpose agent for researching complex questions, ...
- writer: 写手
注意:一次只派一个。'漏了占位符:
HarnessProfile(tool_description_overrides={
"task": "【定制】把活派给下属。(忘了写占位符)",
})'【定制】把活派给下属。(忘了写占位符)'
-> 模型看不到有哪些子智能体可用了不报错,但子智能体清单彻底消失。 模型只能瞎猜 subagent_type 该填什么,结果是委派功能实质失效(第 47 章 §3.3 说过 description 是模型选子智能体的唯一依据)。
7.4. excluded_tools #
排除 三个写工具 工具 5 个: ['ls', 'read_file', 'glob', 'grep', 'task']
排除 task 工具 7 个: ['ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep']
排除 不存在的工具 工具 8 个: [...全部...]三点:
- 排除写工具能做出一个只读智能体(比第 43 章的权限规则更彻底——工具根本不存在)
- 排除
task也可以,这比第 47 章 §9 那个「禁用 GP + 不传 subagents」的两条件写法简单 - 排除不存在的工具名不报错(和
excluded_middleware不同,那个会报错)
第 3 点是个不对称设计,实际用起来要注意:工具名写错了不会有任何提示。
7.5. extra_middleware #
支持两种写法:
# 直接给实例
HarnessProfile(extra_middleware=[ProfileMw()])
# 或者给工厂函数(延迟构造)
def factory():
return [ProfileMw()]
HarnessProfile(extra_middleware=factory)用实例列表: [..., 'PatchToolCallsMiddleware', 'ProfileMw', 'AnthropicPromptCachingMiddleware']
用工厂函数: [..., 'PatchToolCallsMiddleware', 'ProfileMw', 'AnthropicPromptCachingMiddleware']效果一样,位置都在核心栈之后、提示缓存之前。用工厂的好处是不会在没匹配上 profile 时白构造一遍(源码里有专门的注释提到这点)。
7.6. general_purpose_subagent #
enabled=False mw=4 工具=['ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep']
改 description mw=5 工具=[..., 'task']enabled=False 时 SubAgentMiddleware 整个消失(5 变 4),task 工具也没了。这是第 47 章 §9 的机制。
GeneralPurposeSubagentProfile 还能改 description 和 system_prompt——用于调整默认子智能体的行为而不是完全换掉它。
8. response_format 结构化输出 #
response_format 是直接透传给 create_agent 的:
"""让最终答案是结构化的。"""
from pydantic import BaseModel, Field
class Report(BaseModel):
"""最终报告。"""
title: str = Field(description="标题")
score: int = Field(description="评分 1~10")
agent = create_deep_agent(model=..., response_format=Report)传给 create_agent 的 response_format: <class '__main__.Report'>用法和第 6 章、第 9 章完全一致,接受 ToolStrategy(...)、ProviderStrategy(...) 或原始 schema。
第 47 章 §7 讲过子智能体也有这个字段,两者是独立的:
主智能体 response_format |
子智能体 response_format |
|
|---|---|---|
| 约束谁 | 最终给用户的答案 | 子智能体交回主智能体的报告 |
| 消费者 | 你的代码 | 主智能体(模型) |
在 deep agent 里用结构化输出要想清楚一件事:deep agent 的产出通常是文件,不是返回值(第 42 章、第 47 章 §4.1)。所以 response_format 一般不用来装载正文,而是装载元信息:
class RunResult(BaseModel):
"""一次运行的结果索引。"""
summary: str = Field(description="不超过 200 字的结论摘要")
artifacts: list[str] = Field(description="产出文件的路径列表")
confidence: float = Field(description="对结论的置信度 0~1")
needs_review: bool = Field(description="是否需要人工复核")正文在 artifacts 指向的文件里,返回值只回一份可程序化处理的索引。这跟 §7.3 那个「返回值窄、文件宽」是同一个思路。
9. 一份定制过的装配 #
把本章的东西拼成一个实际配置:
"""生产环境的定制装配:只读分析型智能体。"""
from deepagents import (create_deep_agent, HarnessProfile,
register_harness_profile, FilesystemMiddleware)
from deepagents.backends import FilesystemBackend
from langchain.agents.middleware import TodoListMiddleware
from pydantic import BaseModel, Field
class AnalysisResult(BaseModel):
"""分析结果索引。"""
summary: str = Field(description="不超过 200 字的结论")
artifacts: list[str] = Field(description="产出文件路径")
needs_review: bool = Field(description="是否需要人工复核")
# 一、按模型注册 profile(key 用字符串规格,避免 §6.2 的坑)
register_harness_profile(
"deepseek",
HarnessProfile(
# 这个模型需要更明确的格式要求
base_system_prompt="输出中文。所有结论必须给出文件路径作为依据。",
# 放在最后的强调
system_prompt_suffix="不确定的地方明确说不确定,不要编造。",
# 精简高频工具的描述,省 schema 开销
tool_description_overrides={
"read_file": "读取文件内容。支持 offset / limit 分段读。",
# 覆盖 task 时务必带占位符
"task": ("把独立的子任务派给下属,一次可派多个。\n\n"
"可用下属:\n{available_agents}\n\n"
"要求:把完整背景写进 description,下属看不到你和用户的对话。"),
},
# 只读智能体:写工具直接不存在
excluded_tools=frozenset({"write_file", "edit_file", "delete"}),
# 自己管上下文压缩
excluded_middleware=frozenset({"SummarizationMiddleware"}),
),
)
agent = create_deep_agent(
model="deepseek:deepseek-v4-flash",
system_prompt="你是数据分析师,负责从日志里定位问题根因。",
backend=FilesystemBackend(root_dir="./workspace"),
middleware=[
# 任务规划(第 47 章 §2)
TodoListMiddleware(),
# 替换默认文件系统 middleware:直接传实例,.name 天然对齐
FilesystemMiddleware(backend=FilesystemBackend(root_dir="./workspace"),
tools=["ls", "read_file", "glob", "grep"]),
],
response_format=AnalysisResult,
)装出来的栈:
1. FilesystemMiddleware ← 被你的实例替换了
2. SubAgentMiddleware
3. PatchToolCallsMiddleware ← Summarization 被 profile 排除了
4. TodoListMiddleware ← 你的 middleware 插在这
5. AnthropicPromptCachingMiddleware
6. _ToolExclusionMiddleware ← profile 有 excluded_tools10. 实战约定与坑 #
- 默认装 5 个 middleware,
SubAgentMiddleware(因为默认 GP 子智能体)和AnthropicPromptCachingMiddleware(无条件)都在里面(§2.1) - 用户 middleware 插在核心栈之后、profile / 提示缓存 / Memory 尾部之前(§2.3)
Memory在提示缓存之后是刻意设计:记忆会变,放在缓存断点后才不会让前缀失效(§2.4)- 继承一个 middleware 但不改
name,得到的是追加而不是替换——栈里会同时有两个(§3.1) - 要替换就显式设
name,或者干脆直接传配好参数的实例(.name天然对齐)(§3.2、§3.3) - 改完数一下栈长度,长度变了就说明是追加不是替换
FilesystemMiddleware和SubAgentMiddleware不可移除,尝试排除在构造HarnessProfile时就报错(§4)FilesystemMiddleware还负责执行permissions——这是它不可移除的真正原因(安全保证)(§4.1)- 可移除的只有三个:Summarization、PatchToolCalls、AnthropicPromptCaching。全排掉剩 2 个(§4.2)
PatchToolCallsMiddleware一般不要排除,它负责修复模型发出的畸形工具调用(§4.2)excluded_middleware排除项必须命中,否则ValueError;私有(下划线开头)名字也拦(§4.3)- profile key 对预构建实例是「类名小写」,跟
_llm_type无关。 这是 profile 不生效的头号原因(§6.2) - 优先用字符串模型规格(
model="deepseek:..."),那条路径的 key 就是你写的字符串(§6.1) - 查表会从精确 key 回退到 provider 前缀,可以注册 provider 级默认值再给个别模型精确覆盖(§6.1)
- 没匹配上会打 WARNING,但仅在注册表非空时。查不到就用
get_model_provider()实测(§6.3) - 同一个 key 重复注册是合并语义,集合字段取并集(§6.4)
excluded_*传frozenset或set,别传 list——单次能用,重复注册会TypeError(§6.4)- 提示词顺序固定是 USER → BASE → SUFFIX,空行分隔(§7.1)
tool_description_overrides是整体替换。read_file默认 1220 字符,精简它能省 schema 开销(§7.2)- 覆盖
task描述必须带{available_agents}占位符,漏了不报错但子智能体清单消失、委派实质失效(§7.3) excluded_tools排除不存在的工具名不报错(和excluded_middleware不对称),工具名写错没有提示(§7.4)excluded_tools能排除task,比「禁用 GP + 不传 subagents」的两条件写法简单(§7.4)extra_middleware用工厂函数可以避免 profile 没匹配上时白构造(§7.5)HarnessProfileConfig是纯数据版(少extra_middleware),用于从配置文件加载(§5)- deep agent 的
response_format装元信息、不装正文——正文在文件里,返回值只回索引(§8)
11. 练习 #
打印你自己的栈。 用 §2.1 那个拦截手法,把你现有 agent 的 middleware 列表打出来,对照 §2.3 的顺序表确认每一项为什么在那。
踩同名替换的坑。 继承
FilesystemMiddleware但不改name,传进去,数一下栈长度。然后显式设name,再数一次。查你的 profile key。 对你实际用的模型调
get_model_provider()和get_model_identifier(),看该注册什么 key。然后注册一个带base_system_prompt的 profile,确认它出现在系统提示词里。踩重复注册的坑。 对同一个 key 注册两次带
excluded_middleware=["X"](list)的 profile,确认第二次报TypeError。改成frozenset再试。漏掉占位符。 覆盖
task描述但不写{available_agents},然后接真模型让它派活,看它填了什么subagent_type。做只读智能体。 用
excluded_tools排掉三个写工具,让智能体尝试写文件,看它的反应(工具根本不存在,与第 43 章权限拒绝的表现不同)。最小栈。 排除三个可移除的 middleware,确认只剩 2 个,然后跑一个简单任务看是否还正常。
(选做)结构化索引。 定义 §8 那个
RunResult,让智能体做一次调研:正文写进文件、返回值只回摘要和文件路径列表。检查返回的artifacts路径是否真实存在。
12. 本章小结 #
- 默认装 5 个 middleware,全量能到 13 个(含条件装载项)。完整顺序见 §2.3 的表。
- 用户 middleware 插在核心栈之后、尾部之前,多个按传入顺序,且在 profile 的
extra_middleware之前。 - 顺序不可随意调整:Memory 在提示缓存之后,是为了让记忆更新不失效缓存前缀。
- 同名替换的判定很严格:
.name必须精确相等。继承而不改name会得到「追加」而非「替换」,栈里同时存在两个。 - 最稳的替换方式是直接传配好参数的实例,
.name天然对齐。 FilesystemMiddleware和SubAgentMiddleware不可移除,因为前者还执行permissions(安全保证)、后者支撑task。尝试排除在构造 profile 时就报错。- 可移除的只有三个,全排掉剩 2 个。
PatchToolCallsMiddleware一般不该排除。 excluded_middleware排除项必须命中,写错报错;excluded_tools写错不报错——这是个不对称设计。- profile 不生效的头号原因是 key 没匹配:对预构建实例,
provider是类名小写,跟_llm_type无关。优先用字符串模型规格。 - 查表从精确 key 回退到 provider 前缀,支持「provider 级默认 + 个别模型覆盖」的分层。
- 同一 key 重复注册是合并,集合字段取并集;
excluded_*必须传frozenset或set。 - 提示词组装固定是 USER → BASE → SUFFIX。
- 覆盖
task描述必须带{available_agents},漏了会静默让委派失效。 tool_description_overrides是整体替换,也是精简 schema 开销的手段。response_format在 deep agent 里装元信息(摘要 + 文件路径 + 置信度),正文留在文件里。