0基础学会Agent Harness工程(06):Subagent用干净上下文拆任务

本篇对应的官方文档

本篇主要内容
第 05 篇已经把复杂任务写成可观察的 Todo 清单,但局部检索产生的文件、日志和失败尝试仍会进入主 messages。本篇增加 task 工具、spawn_subagent()、独立 SUB_SYSTEM 与全新的 child messages,沿“父 Agent 分派—子 Agent 调用工具—summary 回填父循环”的链路,解释上下文隔离怎样成立、治理 Hook 为什么仍需复用,以及摘要、轮次上限和共享工作目录留下了哪些工程边界。

下篇预告
独立上下文解决了“局部调查污染主线”,但所有能力说明如果在启动时全部写进 system prompt,仍会持续占用上下文。第 07 篇将把能力说明拆成 Skill 索引与按需加载正文。

一、Todo已经能记录步骤,为什么主上下文仍会越来越乱

第 05 篇给 Agent 增加了 CURRENT_TODOS。模型可以把“扫描项目、定位认证实现、比较两种修复方案、修改文件、运行检查”写成五项计划,并在每一步完成后更新状态。Harness 因而知道任务走到了哪里,也能在连续三轮没有更新清单时插入 reminder。

但 Todo 只描述工作进度,不承载工作的全部中间证据。主 Agent 为了完成“比较认证实现”可能读取十几个文件,搜索数百行调用关系,再运行若干命令。每次工具调用都产生 assistant tool_calls 与对应 role="tool" 结果;这些内容必须进入 messages,否则模型下一轮看不到刚才发生了什么。任务越复杂,主上下文里越容易混入局部路径、无关日志、失败尝试和已经淘汰的假设。

问题不只是“消息太多”。更危险的是注意力边界消失:最终目标是给出认证修复方案,主 Agent 却需要同时保留每个候选文件的细节。局部调查与全局决策使用同一份上下文,等于把研究过程、执行过程和交付判断压在一个工作台上。

第 06 篇引入 Subagent,不是为了凭空增加一个更聪明的模型,而是把局部工作放进另一份消息状态。父 Agent 只决定“要调查什么”,子 Agent 在全新的 messages 中读取文件、调用工具、整理结论,最后只把一段总结作为 task 工具结果交回父循环。

需要先分清四个对象:

  • parent messages:保留用户目标、父 Agent 决策和子任务总结;
  • child messages:只保存当前子任务所需的调用与结果;
  • summary:子循环最终返回的文本,不等于完整过程;
  • WORKDIR:父子 Agent 仍共享的真实工作目录。

这四个对象在一次委派中的可见范围与共享关系如下图所示。

父消息、子消息、共享工作目录与摘要的边界
上下文隔离不等于环境隔离。子 Agent 虽然看不到父级完整历史,却仍能读写相同目录,也会产生真实副作用。若父子同时修改同一文件,独立 messages 不会阻止冲突;它只保证局部工具记录不自动进入父级模型输入。

这也解释了为什么本篇场景选择“分派一次检索子任务”。检索主要产生大量中间信息,适合在子上下文中消化;最终只需返回文件位置、关键调用链和证据摘要。若把高风险修改也直接交给子 Agent,就必须额外处理所有权、审批、幂等和冲突,这些不属于当前单文件示例的能力。

局部检索留在子上下文,全局判断留在父上下文
因此,本篇只回答一个问题:s06_subagent.py 怎样把一次 task 工具调用变成独立子循环,并把父级真正需要的结果压缩成一条工具消息。

二、一条task调用怎样启动独立子循环

父 Agent 看到的 taskbashread_file 没有协议级区别。它同样是一个 function tool:Schema 声明工具名、用途和 description 参数;模型通过 tool_calls 提出调用;Harness 找到 spawn_subagent handler 执行;返回值按原来的 tool_call_id 回填。

真正的变化发生在 handler 内部。spawn_subagent(description) 没有复制父级 messages,而是从一条新的 user 消息开始:

def spawn_subagent(description: str) -> str:
    messages = [
        {"role": "user", "content": description}
    ]

    for _ in range(30):
        response = chat_completion(
            model=MODEL,
            system=SUB_SYSTEM,
            messages=messages,
            tools=openai_tools(SUB_TOOLS),
            max_tokens=8000,
        )
        ...

这里的局部变量也叫 messages,但它与主程序传入 agent_loop(history) 的列表不是同一个对象。父级历史不会被拼进去,父级 Todo、此前工具输出和用户的其他对话也不会自动出现在子调用中。子 Agent 能看到的起点只有 SUB_SYSTEMdescription

task工具从父循环进入独立child messages的时序
description 因而成为父子之间最重要的输入合同。若父 Agent只传“去看看认证”,子 Agent 缺少目标文件范围、期望产物和停止条件,只能猜测;若直接把父级全部历史塞进描述,又重新制造上下文污染。较好的委派文本应包含目标、范围、可用证据和返回格式,同时省略与局部任务无关的讨论。

子循环拥有自己的工具集合。工具集合必须在委派开始前确定,因为它同时决定子 Agent 能观察什么和能改变什么。若检索任务只需要 globread_file,继续暴露 write_fileedit_filebash 会扩大不必要的副作用面。当前案例保留五种工具,是为了展示子循环仍能独立完成通用编码任务,不代表每次子任务都应该获得相同权限。

SUB_TOOLS = [
    bash_tool,
    read_file_tool,
    write_file_tool,
    edit_file_tool,
    glob_tool,
]

SUB_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
}

关键不是工具数量,而是其中没有 task。父 Agent 能通过 task 启动子 Agent,子 Agent 却不能继续启动孙级 Agent。递归限制不是 OpenAI API 自动提供的,而是 Harness 通过不同的 Tool Schema 主动收窄能力集合。

父Agent拥有task工具,子Agent工具集中主动移除task
SUB_SYSTEM 也单独声明“完成给定任务、返回简洁总结、不要继续委派”。工具集合提供硬结构限制,提示词提供行为要求。只写“不要递归”却仍把 task 暴露给子模型,约束仍依赖模型是否遵循;直接从 SUB_TOOLS 移除 task,才让当前路径在代码层无法递归。

子 Agent 每轮遵循与主 Agent 相同的工具闭环:模型产生 tool_calls,Harness 解析 arguments,执行 handler,再追加 role="tool" 消息。独立上下文不是另一个协议,仍然是多次 Chat Completions 请求,只是每次请求使用另一份消息列表。

父子Agent分别向模型发起请求,API本身不知道父子关系
这一边界必须写清。OpenAI-compatible 服务接收到的是普通 messagestools 和工具结果,不会自动创建“父 Agent”或“子 Agent”。task 名称、子循环、30 轮上限以及 summary-only 返回都属于本地 Harness 编排。

三、summary怎样接回主链

s06_subagent.py 保留第 05 篇的工具 handler、Todo 状态、Hook Registry 和主 agent_loop()。本篇新增代码集中在六个坐标:

  1. SUB_SYSTEM 定义子任务角色和停止目标;
  2. SUB_TOOLS / SUB_HANDLERS 收窄子能力;
  3. extract_text() 统一提取 assistant 文本;
  4. spawn_subagent() 建立局部循环;
  5. task Schema 注册到父级 TOOLS
  6. TOOL_HANDLERS["task"] 把名称映射到子循环。

s06在原Agent Loop上增加子循环的代码坐标
沿图中的代码坐标向下看,治理 Hook、轮次保险丝和最终文本提取共同决定子循环怎样安全返回:

# 权限检查仍复用父级 Hook。
blocked = trigger_hooks("PreToolUse", block, arguments)
if blocked:
    output = str(blocked)
else:
    handler = SUB_HANDLERS.get(name)
    output = (
        handler(**arguments)
        if handler
        else f"Unknown: {name}"
    )
    trigger_hooks("PostToolUse", block, output)

# 最多进行 30 轮,并在没有工具调用时结束。
for _ in range(30):
    ...
    if not message.tool_calls:
        break

# 从后向前提取最近的非空 assistant 文本作为返回值。
result = ""
for msg in reversed(messages):
    if msg.get("role") == "assistant":
        result = extract_text(msg.get("content"))
        if result:
            break

if not result:
    result = (
        "Subagent stopped after 30 turns "
        "without final answer."
    )
return result

如果子 Agent 绕过 PreToolUse,父级的 deny list 和日志 Hook 就只保护主循环,委派反而成为权限旁路。代码复用 Hooks,说明上下文独立与治理策略继承是两条不同的设计轴。

30 轮限制的是模型轮次,不是工具调用总数。一条 assistant 消息可以并行提出多个工具调用,因此 handler 次数可能超过 30;代码也没有时间、Token 和费用预算,轮数只是最小保险丝。

最近文本兜底能够避免返回空字符串,却不能证明任务完整完成。达到上限时,最近文本可能只是“我先读取配置”,真正结果位于其后。生产接口应区分 completedmax_turnsfailedcancelled

这也是文本返回值无法替代明确运行状态的原因。

这段逻辑避免返回空字符串,却不能保证找到的文本是完整总结。达到上限时,最近的 assistant 文本可能只是“我先读取配置”,真正的工具结果位于其后。为了保持教学代码简单,案例选择了“最近文本兜底”;生产实现应明确区分 completedmax_turnsfailedcancelled

一次静态推演可以沿四步展开:

父 Agent:调用 task(description="定位认证入口并返回文件与调用链")
→ 子 Agent:在 fresh messages 中调用 globread_file
→ 子 Agent:生成“入口位于 auth.py,调用链为 A→B→C”的总结
→ 父 Agent:收到与原 tool_call_id 配对的一条工具结果

这四步在消息状态中的变化可以压缩成下面这条状态链。

一次检索子任务从分派到summary回填的状态变化
父级最终只保留 summary,不保留子级中间 tool_calls。这正是隔离带来的收益,也是证据损失的来源。总结说“auth.py 第 80 行存在风险”,父级上下文并没有自动得到原始文件片段、搜索命令和失败尝试;若后续需要复核,只能重新读取或让子 Agent 返回证据引用。

更可靠的委派可以把返回格式写进 description,例如要求固定包含“结论、文件位置、证据、未确认项、是否修改环境”五部分。这样仍然是自然语言 summary,但父级至少能够判断结论是否缺少定位信息,也能在子级报告“已修改环境”时暂停后续并检查差异。仅要求“简洁总结”会鼓励模型压缩文字,却没有定义哪些信息不能省略。

委派还需要处理信息新鲜度。父级发出任务以后,工作目录可能被其他过程修改;子级总结引用的是调查时看到的版本,父级收到结果时文件已经变化。当前代码没有记录文件哈希、Git 状态或调查时间,因此 summary 只代表一次瞬时观察。涉及关键决策时,父级应在真正执行修改前重新读取目标位置,而不是永久信任旧总结。

另一个容易忽略的问题是错误如何表达。spawn_subagent() 返回类型始终是字符串,成功结论、权限拒绝、轮次耗尽和未知工具最终都混在同一文本通道中。父级只能依靠措辞推断状态。生产接口更适合返回结构化对象,例如 statussummaryevidenceside_effectserror,让 Harness 先处理运行状态,再把必要文本交给模型。

四、Subagent隔离上下文,却没有自动解决协作可靠性

第 06 篇的增量可以压缩成一条链:

父级识别局部复杂任务
task 生成结构化委派
spawn_subagent() 创建 fresh messages
→ 子级用受限工具循环完成调查
→ 最终 summary 作为普通 tool result 回到父级

最常见的误解,是把 Subagent 当成“复制一个主 Agent”。当前子级没有父级完整历史、没有 todo_write、没有 task,也没有独立工作目录。它是一个为局部目标临时创建的模型循环,而不是拥有完整自治能力的长期成员。

第一条失败路径是上下文泄漏。如果为了“让子 Agent 知道更多”,直接写成 messages = list(parent_messages),局部循环会继承父级所有噪声,隔离价值消失;父级历史中还可能包含不应传给该任务的敏感信息。

第二条失败路径是摘要失真。子级可能遗漏关键反例,把不确定推断写成确定结论,或者在达到轮次上限时返回半成品。父级若把 summary 当作原始证据继续修改文件,错误会沿调用链放大。

第三条失败路径是共享环境冲突。父子虽然拥有不同 messages,却共用 WORKDIR。两边同时写同一文件时,后写入者可能覆盖前者;一个子任务运行测试时,另一个子任务可能正在修改依赖。

上下文泄漏、摘要失真与共享目录冲突三条失败路径
因此,是否委派不能只看任务“长不长”。适合子 Agent 的工作通常具有明确输入、局部证据范围和可压缩产物,例如查找调用链、比较几份实现、整理错误日志。需要持续与用户确认、掌握全局权衡或修改核心共享状态的任务,更适合留在父级。

走向生产时,至少需要补充:

  • 为每次子任务分配 run_id,记录开始、结束和退出原因;
  • 把轮次、时间、Token、费用和工具次数组成联合预算;
  • summary 返回结论、证据引用、未确认项和已执行副作用;
  • 高风险工具继续经过统一权限与审计;
  • 并行子任务使用工作树、容器或锁隔离写入;
  • 支持取消、超时、重试和结果去重;
  • 限制委派深度,避免任务树指数增长。

并行数量也必须单独限制。即使禁止递归,父 Agent 仍可能在一条 assistant 消息中并列提出十个 task 调用。当前主循环会顺序执行这些 handler,不会真正并行,但会造成长时间阻塞和重复调查;将来改为并发后,还会同时争用目录、API 配额和日志资源。合理的调度器需要限制在途任务数,对相同目标做合并,并允许父级取消已经失去价值的调查。

Subagent 与长期协作成员也应分开。当前子循环从一次 description 出生,返回 summary 后局部 messages 被丢弃,没有稳定身份、邮箱、任务队列和恢复点。它适合一次性局部研究。后续 Agent Teams 章节中的成员会拥有独立生命周期和通信协议,不能因为两者都调用模型就把它们视为同一结构。

可以用一个简单判断决定是否委派:如果父级拿到结果后,只需要消费结论而不需要逐轮参与过程,子任务就具有隔离价值;如果每一步都依赖父级刚形成的新判断,频繁往返会让 summary 变成信息瓶颈。委派粒度太小会增加模型调用与交接成本,粒度太大又会让子级在缺少全局背景时独自承担关键决策。

父级还应对 summary 做最小验收,而不是收到字符串就宣告 Todo 完成。至少检查目标是否回答、证据是否可定位、是否报告副作用、是否存在未确认项。验收失败时可以补充 description 再重试一次,或把任务收回主上下文。这样,Subagent 才是受控的信息压缩边界,而不是把不确定工作简单扔给另一个模型。

从资源角度看,fresh context 也不是免费优化。子 Agent 需要额外模型调用,并重复携带自己的 system、工具 Schema 和任务描述。它节省的是父级长期上下文,不一定降低总 Token 或总延迟。工程选择应比较主线清晰度、调用成本、可并行性与结果可验证性,而不是看到“上下文隔离”就默认所有复杂步骤都要委派。

教学Subagent与生产任务执行单元的能力边界
本篇没有配置 API,也没有运行真实模型。fresh messages、工具集合收窄、Hook 复用、30 轮上限和 summary 回填来自对 s06_subagent.py 的静态控制流分析。静态分析能确认代码在给定响应下怎样组织父子消息,不能证明模型一定会正确委派或生成可靠总结。

到这里,主 Agent 已能把局部复杂工作放进独立上下文。但新的问题已经出现:随着工具、规范和领域流程不断增加,如果所有说明都在启动时完整塞进 SYSTEM,即使有 Subagent,每次模型调用仍要携带大量当前任务根本用不到的能力文本。第 07 篇将把这些能力说明保存为 Skill,只在模型明确需要时加载全文。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐