- Published on
Agent Harness 工程:Subagent 与上下文隔离
- Authors

- Name
- XiaoLeiJun
Agent Harness 工程:Subagent 与上下文隔离
上一篇我们给 Agent 加了 TodoWrite,让它在复杂任务开始前先列清单,并在执行过程中持续更新进度。
这已经让 Agent 从“想到哪做到哪”,进化到了“按计划推进任务”。
但 TodoWrite 只能解决一部分问题。
当任务继续变复杂时,Agent 仍然会遇到一个更底层的限制:上下文窗口是有限的,注意力也是有限的。
比如让 Agent 做一个完整功能:
- 先读项目结构;
- 再读相关组件;
- 再找状态管理逻辑;
- 再修改代码;
- 再跑测试;
- 测试失败后继续排查;
- 最后总结修改。
即使有 TodoWrite,messages 里仍然会混进大量过程信息:文件内容、工具调用参数、测试输出、错误栈、模型中间判断、权限提示、审计结果。
这些内容不是完全没用,但它们不一定都应该一直占着主 Agent 的上下文。
所以这一篇继续往前走:给 Harness 增加 Subagent。
为什么 TodoWrite 之后还需要 Subagent
TodoWrite 维护的是任务状态,它能告诉 Agent:
- 当前计划是什么;
- 哪一步正在进行;
- 哪些步骤已经完成;
- 是否还有遗漏。
但 TodoWrite 不负责压缩过程信息。
如果主 Agent 亲自完成所有细节,它还是要把所有工具结果都塞进自己的 messages。任务越长,上下文越吵。
可以做一个类比。
假设你是一个汽车工厂的负责人。如果发动机、底盘、外观、仪表、测试、采购,每个环节都要你亲手做,你很快就会忙到顾不过来。更合理的方式是:你负责拆任务、定标准、协调进度,每个环节交给对应的人去完成,最后你只接收关键结果。
软件开发里也是一样。
项目经理不应该亲自改每一行代码;主进程不应该亲自执行每一个 worker 的细节;主 Agent 也不应该把所有局部探索都塞进自己的上下文。
Subagent 的核心价值就是:
主 Agent 负责规划、分派和整合;子 Agent 在干净上下文里完成局部任务,并把压缩后的结果返回给主 Agent。
Subagent 把局部探索和局部执行隔离出去,主 Agent 只保留任务计划、关键结论和最终决策。
Subagent 不是更聪明的模型
很多人一提到 Subagent,就容易想成“多找几个模型一起想,答案就更聪明”。
这不是这篇要讲的重点。
在 Harness 工程里,Subagent 首先是一种上下文管理手段。它解决的不是“模型智商不够”,而是“主上下文太乱、太长、太容易分散注意力”。
一个子 Agent 通常应该具备几个特点:
| 特点 | 说明 |
|---|---|
| 上下文干净 | 只接收完成当前子任务需要的信息 |
| 目标单一 | 一次只完成一个明确任务 |
| 工具受限 | 默认只给它必要工具,不要把所有能力都开放 |
| 结果压缩 | 返回结论、证据和建议,而不是把完整过程倒回主上下文 |
| 生命周期短 | 子任务完成就结束,不长期持有状态 |
这里最关键的是“干净上下文”。
如果你把主 Agent 的完整 messages 原样传给子 Agent,那其实没有隔离。子 Agent 只是换了一个循环继续读同一堆噪声,问题并没有解决。
更好的方式是给子 Agent 一个任务包:
- 要完成什么任务;
- 已知背景是什么;
- 可以查看哪些文件或信息;
- 不能做什么;
- 输出格式是什么;
- 最多执行多少步。
子 Agent 完成后,只把整理过的结果返回给主 Agent。
子代理也是一个工具
从 Harness 的角度看,Subagent 不需要被神化。
它其实也是一个工具。
主 Agent 调用 run_subagent,Harness 创建一个新的 Agent Loop,把主 Agent 给出的子任务交进去。子 Agent 在自己的消息历史里执行工具、收集信息、生成结果。最后,Harness 把子 Agent 的最终回答作为 run_subagent 的工具结果写回主 Agent 的 messages。
也就是说,主 Agent 眼里的 Subagent 调用还是熟悉的工具协议:
- 模型请求调用
run_subagent; - Harness 解析参数;
- 权限和 Hook 生效;
- Harness 启动一个隔离的子循环;
- 子循环返回压缩结果;
- 主 Agent 继续推进 Todo。
这样设计的好处是:前几篇搭出来的 TOOLS、TOOL_HANDLERS、权限策略、Hook、TodoWrite,都还能继续复用。
先定义 Subagent 工具
先把 run_subagent 暴露给主 Agent。
这个工具的参数不要太复杂,先保留三个核心字段:
task:子 Agent 要完成的任务;context:主 Agent 已经整理好的必要背景;expected_output:希望子 Agent 按什么格式返回结果。
SUBAGENT_TOOL = {
"type": "function",
"function": {
"name": "run_subagent",
"description": (
"Run an isolated subagent for a focused subtask. "
"Use it when a local investigation or implementation detail "
"would pollute the main agent context."
),
"parameters": {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The focused subtask for the subagent.",
},
"context": {
"type": "string",
"description": "Relevant background selected by the main agent.",
},
"expected_output": {
"type": "string",
"description": "The desired structure of the subagent result.",
},
},
"required": ["task"],
},
},
}
然后把它加到主 Agent 的工具列表里:
TOOLS = [
BASH_TOOL,
LIST_DIR_TOOL,
READ_FILE_TOOL,
WRITE_FILE_TOOL,
EDIT_FILE_TOOL,
TODO_WRITE_TOOL,
SUBAGENT_TOOL,
]
注意,这里是把 SUBAGENT_TOOL 加给主 Agent。
子 Agent 自己不一定拥有这个工具。这个区别非常重要,后面会讲。
子 Agent 的工具应该受限
如果子 Agent 一上来就拥有主 Agent 的全部工具,包括 write_file、edit_file、run_subagent,问题会很快变复杂。
它可能修改主 Agent 不知道的文件;可能自己再创建子 Agent;可能把一个局部任务扩张成更大的任务;也可能让权限审批链条变得很难追踪。
教学版里建议先从只读子 Agent 开始。
比如只给它目录和文件读取能力:
SUBAGENT_TOOLS = [
LIST_DIR_TOOL,
READ_FILE_TOOL,
]
SUBAGENT_TOOL_HANDLERS: dict[str, Callable[..., str]] = {
"list_dir": list_dir,
"read_file": read_file,
}
这意味着子 Agent 可以帮主 Agent 调查问题、阅读代码、整理结论,但不能直接改文件。
如果后面确实需要让子 Agent 修改文件,也应该先做更严格的边界:
- 限制它只能修改某个目录;
- 限制它只能使用某些工具;
- 限制它最多执行几轮;
- 要求它返回修改摘要;
- 让主 Agent 或用户审批关键动作。
更重要的是:不要默认让子 Agent 调用 run_subagent。
否则很容易出现递归派生:主 Agent 创建子 Agent,子 Agent 再创建孙 Agent,孙 Agent 继续创建新的 Agent。即使每一层都“看起来合理”,整体也会变得难以控制。
一个简单规则是:
子 Agent 默认不再派生自己的子 Agent。
实现 run_subagent
现在实现 run_subagent。
它和主 Agent Loop 很像,只是有自己的系统提示词、自己的消息历史、自己的工具列表和自己的最大步数。
SUBAGENT_SYSTEM_PROMPT = """
你是一个子代理,只负责完成主 Agent 分配给你的局部任务。
要求:
1. 只处理当前任务,不要扩展目标。
2. 优先使用给定 context,不要假设没有提供的信息。
3. 如果需要读取文件,只读取和任务直接相关的文件。
4. 不要修改文件,不要执行破坏性操作。
5. 最终回答要简洁,包含结论、关键证据和建议下一步。
"""
def build_subagent_user_message(
task: str,
context: str = "",
expected_output: str = "",
) -> str:
return f"""
任务:
{task}
已知背景:
{context or "没有额外背景。"}
期望输出:
{expected_output or "请返回结论、关键证据和建议下一步。"}
""".strip()
然后写一个子 Agent 专用的工具执行函数。
它只从 SUBAGENT_TOOL_HANDLERS 里取工具,所以即使主 Agent 拥有更多工具,子 Agent 也调用不到。
def run_subagent_tool_call(tool_call: Any) -> str:
name = tool_call.function.name
handler = SUBAGENT_TOOL_HANDLERS.get(name)
if handler is None:
return f"Error: tool {name} is not available to subagent."
try:
arguments = json.loads(tool_call.function.arguments or "{}")
except json.JSONDecodeError as exc:
return f"Error: invalid JSON arguments for {name} - {exc}"
if not isinstance(arguments, dict):
return f"Error: arguments for {name} must be a JSON object."
try:
return handler(**arguments)
except TypeError as exc:
return f"Error: invalid arguments for {name} - {exc}"
except Exception as exc:
return f"Error: subagent tool {name} failed - {exc}"
最后是子循环本身:
def run_subagent(
task: str,
context: str = "",
expected_output: str = "",
max_steps: int = 6,
) -> str:
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": build_subagent_user_message(
task=task,
context=context,
expected_output=expected_output,
),
}
]
max_steps = min(max_steps, 8)
for _ in range(max_steps):
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": SUBAGENT_SYSTEM_PROMPT}]
+ messages,
tools=SUBAGENT_TOOLS,
)
assistant_message = response.choices[0].message
if not assistant_message.tool_calls:
return (assistant_message.content or "").strip()[:12000]
messages.append(
{
"role": "assistant",
"content": assistant_message.content or "",
"tool_calls": [
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
},
}
for tool_call in assistant_message.tool_calls
],
}
)
for tool_call in assistant_message.tool_calls:
tool_result = run_subagent_tool_call(tool_call)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
return "Error: subagent reached max_steps before producing a final result."
这段代码里有几个刻意的限制。
第一,子 Agent 的 messages 是新建的。它不会继承主 Agent 的完整历史,只接收主 Agent 整理出来的 task、context 和 expected_output。
第二,子 Agent 使用 SUBAGENT_TOOLS,不是主 Agent 的 TOOLS。这保证了工具边界。
第三,max_steps 被限制住了。子 Agent 是短生命周期 worker,不应该无限运行。
第四,最终结果被截断到 12000 字符。真实项目里你可以用更精细的结果压缩策略,但教学版先避免子 Agent 把大段原始内容倒回主上下文。
主 Agent 不把完整历史塞给子 Agent,只传必要任务包;子 Agent 也只返回压缩后的结果。
注册到 TOOL_HANDLERS
有了 run_subagent(),再把它注册到主 Agent 的处理器里:
TOOL_HANDLERS: dict[str, Callable[..., str]] = {
"bash": bash,
"list_dir": list_dir,
"read_file": read_file,
"write_file": write_file,
"edit_file": edit_file,
"run_todo_write": run_todo_write,
"run_subagent": run_subagent,
}
主 Agent Loop 不需要大改。
对它来说,run_subagent 和 read_file、edit_file 一样,都是一次工具调用。区别只是:read_file 直接读取文件,而 run_subagent 会在 Harness 内部启动一个短生命周期的子循环。
这也是前几篇不断抽象工具系统的回报:只要工具协议稳定,后面就可以把很复杂的能力包装成一个工具。
权限策略怎么处理
run_subagent 不直接修改文件,但它会消耗模型调用次数,也可能读取项目内容。所以权限策略要看你给子 Agent 开放了什么工具。
如果子 Agent 是只读的,可以默认放行:
def check_permission(tool_name: str, arguments: dict[str, Any]) -> PermissionResult:
if tool_name == "run_subagent":
return PermissionResult(
"allow",
"subagent uses isolated read-only tools",
)
# 其他规则沿用前几篇
如果子 Agent 具备写文件、执行命令、访问网络的能力,就不应该简单放行。可以把权限策略升级成:
- 创建写入型子 Agent 前需要用户确认;
- 子 Agent 内部每次高风险工具调用仍然走权限 Hook;
- 子 Agent 的工具调用记录进审计日志;
- 子 Agent 的最终结果必须说明它执行了哪些关键操作。
也就是说,Subagent 不是绕过权限系统的通道。它只是一个新的工具,仍然应该被 Harness 管住。
和 TodoWrite 怎么配合
TodoWrite 和 Subagent 的关系很自然。
TodoWrite 负责主 Agent 的任务计划;Subagent 负责完成其中某个局部步骤。
比如用户让 Agent “排查登录失败,并修复问题”。主 Agent 可以先写 Todo:
{
"todos": [
{
"id": "inspect-auth-flow",
"content": "调查登录流程和失败位置",
"status": "in_progress"
},
{
"id": "fix-login-bug",
"content": "根据调查结果修复登录问题",
"status": "pending"
},
{
"id": "verify-login",
"content": "运行测试或手动验证登录流程",
"status": "pending"
}
]
}
然后把第一个步骤交给子 Agent:
{
"task": "调查登录流程,找出登录失败最可能发生的位置。",
"context": "项目是一个 Next.js 应用。请重点查看 app、components、lib 目录中和 auth、login、session 相关的代码。",
"expected_output": "返回:1. 关键文件;2. 登录流程概述;3. 最可疑的问题点;4. 建议主 Agent 下一步修改哪里。"
}
子 Agent 读取文件、整理结果后,返回类似:
结论:登录失败最可能发生在 lib/auth/session.ts 的 cookie 解析逻辑。
关键证据:
- app/login/page.tsx 提交后调用 loginAction。
- loginAction 成功后写入 session cookie。
- lib/auth/session.ts 读取 cookie 时使用了旧字段 user_id。
建议下一步:
- 主 Agent 修改 session 解析字段为 userId。
- 修改后运行 auth 相关测试。
主 Agent 拿到这个结果后,再更新 Todo:
inspect-auth-flow标为completed;fix-login-bug标为in_progress;- 然后自己执行
edit_file修改代码。
这样主 Agent 不需要保留子 Agent 读取过的所有文件全文,只需要保留结论和证据。
子代理什么时候有用
Subagent 适合用在局部但信息量大的任务上。
比如:
- 在大项目里调查某个功能的实现路径;
- 阅读多个文件后总结模块边界;
- 根据错误栈定位可能的失败原因;
- 让一个只读 worker 先收集证据;
- 让测试 worker 专门分析失败输出;
- 让文档 worker 总结某个目录的设计。
它不适合用在很小的任务上。
如果只是读一个文件、改一行配置、解释一个函数,主 Agent 自己做就好。强行创建子 Agent 只会增加延迟、成本和复杂度。
一个简单判断是:
如果某个子任务会产生大量中间信息,但主 Agent 最后只需要少量结论,就适合交给 Subagent。
常见坑
Subagent 很有用,但也容易做乱。
第一,不要把完整上下文传给子 Agent。 子 Agent 的价值就在于上下文隔离。如果把主上下文原样传过去,它只是在另一个窗口里继续承受同样的噪声。
第二,不要让子 Agent 默认拥有全部工具。 工具越多,行为越不可控。先从只读能力开始,确认边界后再逐步开放写入和命令执行。
第三,不要让子 Agent 无限递归。 默认不把 run_subagent 暴露给子 Agent。如果确实要支持多层 Agent,需要单独设计深度限制、预算限制和审计。
第四,不要让子 Agent 返回流水账。 主 Agent 需要的是结论、证据和建议,不是另一份完整 messages。
第五,不要把责任丢给子 Agent。 主 Agent 仍然负责最终决策。子 Agent 的结果是输入,不是自动执行的真理。
小结
这一篇在 TodoWrite 的基础上,为 Agent Harness 增加了 Subagent:
- 用
run_subagent把子代理包装成主 Agent 可以调用的工具; - 子 Agent 使用独立
messages,避免污染主上下文; - 子 Agent 使用受限工具集,默认不具备写文件和继续派生子代理的能力;
- 用
max_steps和结果截断控制子 Agent 的生命周期和输出大小; - 让 TodoWrite 负责主线计划,Subagent 负责局部探索和压缩结果;
- 继续让权限策略和 Hook 管住 Subagent,而不是绕过 Harness。
到这里,Agent Harness 已经有了一个更完整的工作形态:
- 主 Agent 维护目标和 Todo;
- 工具系统提供真实动作;
- 权限和 Hook 管控工具调用;
- Subagent 承担局部任务,减少主上下文污染。
下一篇继续看最近很常见的一个概念:Skill。现在很多 Agent 产品都在讲 Skill,但它本质上并不神秘。我们会把 Skill 拆开看:它到底是提示词、工具、文件、流程模板,还是一种按需加载的上下文组织方式。