1. 本章目标 #

前面十章每一章都在往智能体上加东西:技能、记忆、摘要、子智能体、审批。这些东西都是 middleware,它们堆在同一个栈里、按固定顺序执行。

这一章把那个栈完整拆开。这不是为了满足好奇心——顺序决定行为。第 45 章说过 Memory 要放在提示缓存之后,第 46 章说过摘要在提示缓存之前,这些结论都来自装配顺序。不理解顺序,定制 middleware 时踩的坑会非常难查。

学完你应能:

前置依赖: 第 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

两个值得注意的点:

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. HumanInTheLoopMiddleware

2.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. AnthropicPromptCachingMiddleware

2.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.

逻辑是这样的:

  1. 提示缓存的原理是给系统提示词的前缀打断点,缓存这个前缀
  2. Memory 的内容会变(智能体会自己更新记忆,第 45 章 §6)
  3. 如果 Memory 在缓存断点之前,那记忆一改、缓存前缀就变了,整份缓存失效
  4. 放在断点之后,记忆怎么改都不影响前缀

这就是第 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 的检查清单是:

  1. 写好子类,显式设 name 等于要替换的那个
  2. 传进 middleware=
  3. 数一下栈的长度有没有变——变了就说明是追加不是替换

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]

三个全排掉只剩两个必需的。 这是能达到的最小栈。

什么时候真要这么做?

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

依次尝试:

  1. f"{provider}:{identifier}"(仅当两者都有、且 identifier 不含 :)
  2. identifier(仅当它本身含 :)
  3. 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 不支持 |。

两条结论:

  1. excluded_middleware 和 excluded_tools 传 frozenset 或 set,别传 list(单次注册时 list 能用,重复注册就炸)
  2. 同一个 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,用空行分隔。三者都可以缺省。

用途分工:

传 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 个: [...全部...]

三点:

  1. 排除写工具能做出一个只读智能体(比第 43 章的权限规则更彻底——工具根本不存在)
  2. 排除 task 也可以,这比第 47 章 §9 那个「禁用 GP + 不传 subagents」的两条件写法简单
  3. 排除不存在的工具名不报错(和 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_tools

10. 实战约定与坑 #

  1. 默认装 5 个 middleware,SubAgentMiddleware(因为默认 GP 子智能体)和 AnthropicPromptCachingMiddleware(无条件)都在里面(§2.1)
  2. 用户 middleware 插在核心栈之后、profile / 提示缓存 / Memory 尾部之前(§2.3)
  3. Memory 在提示缓存之后是刻意设计:记忆会变,放在缓存断点后才不会让前缀失效(§2.4)
  4. 继承一个 middleware 但不改 name,得到的是追加而不是替换——栈里会同时有两个(§3.1)
  5. 要替换就显式设 name,或者干脆直接传配好参数的实例(.name 天然对齐)(§3.2、§3.3)
  6. 改完数一下栈长度,长度变了就说明是追加不是替换
  7. FilesystemMiddleware 和 SubAgentMiddleware 不可移除,尝试排除在构造 HarnessProfile 时就报错(§4)
  8. FilesystemMiddleware 还负责执行 permissions——这是它不可移除的真正原因(安全保证)(§4.1)
  9. 可移除的只有三个:Summarization、PatchToolCalls、AnthropicPromptCaching。全排掉剩 2 个(§4.2)
  10. PatchToolCallsMiddleware 一般不要排除,它负责修复模型发出的畸形工具调用(§4.2)
  11. excluded_middleware 排除项必须命中,否则 ValueError;私有(下划线开头)名字也拦(§4.3)
  12. profile key 对预构建实例是「类名小写」,跟 _llm_type 无关。 这是 profile 不生效的头号原因(§6.2)
  13. 优先用字符串模型规格(model="deepseek:..."),那条路径的 key 就是你写的字符串(§6.1)
  14. 查表会从精确 key 回退到 provider 前缀,可以注册 provider 级默认值再给个别模型精确覆盖(§6.1)
  15. 没匹配上会打 WARNING,但仅在注册表非空时。查不到就用 get_model_provider() 实测(§6.3)
  16. 同一个 key 重复注册是合并语义,集合字段取并集(§6.4)
  17. excluded_* 传 frozenset 或 set,别传 list——单次能用,重复注册会 TypeError(§6.4)
  18. 提示词顺序固定是 USER → BASE → SUFFIX,空行分隔(§7.1)
  19. tool_description_overrides 是整体替换。read_file 默认 1220 字符,精简它能省 schema 开销(§7.2)
  20. 覆盖 task 描述必须带 {available_agents} 占位符,漏了不报错但子智能体清单消失、委派实质失效(§7.3)
  21. excluded_tools 排除不存在的工具名不报错(和 excluded_middleware 不对称),工具名写错没有提示(§7.4)
  22. excluded_tools 能排除 task,比「禁用 GP + 不传 subagents」的两条件写法简单(§7.4)
  23. extra_middleware 用工厂函数可以避免 profile 没匹配上时白构造(§7.5)
  24. HarnessProfileConfig 是纯数据版(少 extra_middleware),用于从配置文件加载(§5)
  25. deep agent 的 response_format 装元信息、不装正文——正文在文件里,返回值只回索引(§8)

11. 练习 #

  1. 打印你自己的栈。 用 §2.1 那个拦截手法,把你现有 agent 的 middleware 列表打出来,对照 §2.3 的顺序表确认每一项为什么在那。

  2. 踩同名替换的坑。 继承 FilesystemMiddleware 但不改 name,传进去,数一下栈长度。然后显式设 name,再数一次。

  3. 查你的 profile key。 对你实际用的模型调 get_model_provider() 和 get_model_identifier(),看该注册什么 key。然后注册一个带 base_system_prompt 的 profile,确认它出现在系统提示词里。

  4. 踩重复注册的坑。 对同一个 key 注册两次带 excluded_middleware=["X"](list)的 profile,确认第二次报 TypeError。改成 frozenset 再试。

  5. 漏掉占位符。 覆盖 task 描述但不写 {available_agents},然后接真模型让它派活,看它填了什么 subagent_type。

  6. 做只读智能体。 用 excluded_tools 排掉三个写工具,让智能体尝试写文件,看它的反应(工具根本不存在,与第 43 章权限拒绝的表现不同)。

  7. 最小栈。 排除三个可移除的 middleware,确认只剩 2 个,然后跑一个简单任务看是否还正常。

  8. (选做)结构化索引。 定义 §8 那个 RunResult,让智能体做一次调研:正文写进文件、返回值只回摘要和文件路径列表。检查返回的 artifacts 路径是否真实存在。

12. 本章小结 #

  1. 默认装 5 个 middleware,全量能到 13 个(含条件装载项)。完整顺序见 §2.3 的表。
  2. 用户 middleware 插在核心栈之后、尾部之前,多个按传入顺序,且在 profile 的 extra_middleware 之前。
  3. 顺序不可随意调整:Memory 在提示缓存之后,是为了让记忆更新不失效缓存前缀。
  4. 同名替换的判定很严格:.name 必须精确相等。继承而不改 name 会得到「追加」而非「替换」,栈里同时存在两个。
  5. 最稳的替换方式是直接传配好参数的实例,.name 天然对齐。
  6. FilesystemMiddleware 和 SubAgentMiddleware 不可移除,因为前者还执行 permissions(安全保证)、后者支撑 task。尝试排除在构造 profile 时就报错。
  7. 可移除的只有三个,全排掉剩 2 个。PatchToolCallsMiddleware 一般不该排除。
  8. excluded_middleware 排除项必须命中,写错报错;excluded_tools 写错不报错——这是个不对称设计。
  9. profile 不生效的头号原因是 key 没匹配:对预构建实例,provider 是类名小写,跟 _llm_type 无关。优先用字符串模型规格。
  10. 查表从精确 key 回退到 provider 前缀,支持「provider 级默认 + 个别模型覆盖」的分层。
  11. 同一 key 重复注册是合并,集合字段取并集;excluded_* 必须传 frozenset 或 set。
  12. 提示词组装固定是 USER → BASE → SUFFIX。
  13. 覆盖 task 描述必须带 {available_agents},漏了会静默让委派失效。
  14. tool_description_overrides 是整体替换,也是精简 schema 开销的手段。
  15. response_format 在 deep agent 里装元信息(摘要 + 文件路径 + 置信度),正文留在文件里。