Published on

Agent Harness 工程:Subagent 与上下文隔离

Authors
  • avatar
    Name
    XiaoLeiJun
    Twitter

Agent Harness 工程:Subagent 与上下文隔离

上一篇我们给 Agent 加了 TodoWrite,让它在复杂任务开始前先列清单,并在执行过程中持续更新进度。

这已经让 Agent 从“想到哪做到哪”,进化到了“按计划推进任务”。

但 TodoWrite 只能解决一部分问题。

当任务继续变复杂时,Agent 仍然会遇到一个更底层的限制:上下文窗口是有限的,注意力也是有限的。

比如让 Agent 做一个完整功能:

  1. 先读项目结构;
  2. 再读相关组件;
  3. 再找状态管理逻辑;
  4. 再修改代码;
  5. 再跑测试;
  6. 测试失败后继续排查;
  7. 最后总结修改。

即使有 TodoWrite,messages 里仍然会混进大量过程信息:文件内容、工具调用参数、测试输出、错误栈、模型中间判断、权限提示、审计结果。

这些内容不是完全没用,但它们不一定都应该一直占着主 Agent 的上下文。

所以这一篇继续往前走:给 Harness 增加 Subagent

为什么 TodoWrite 之后还需要 Subagent

TodoWrite 维护的是任务状态,它能告诉 Agent:

  • 当前计划是什么;
  • 哪一步正在进行;
  • 哪些步骤已经完成;
  • 是否还有遗漏。

但 TodoWrite 不负责压缩过程信息。

如果主 Agent 亲自完成所有细节,它还是要把所有工具结果都塞进自己的 messages。任务越长,上下文越吵。

可以做一个类比。

假设你是一个汽车工厂的负责人。如果发动机、底盘、外观、仪表、测试、采购,每个环节都要你亲手做,你很快就会忙到顾不过来。更合理的方式是:你负责拆任务、定标准、协调进度,每个环节交给对应的人去完成,最后你只接收关键结果。

软件开发里也是一样。

项目经理不应该亲自改每一行代码;主进程不应该亲自执行每一个 worker 的细节;主 Agent 也不应该把所有局部探索都塞进自己的上下文。

Subagent 的核心价值就是:

主 Agent 负责规划、分派和整合;子 Agent 在干净上下文里完成局部任务,并把压缩后的结果返回给主 Agent。

Subagent 任务分派

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 调用还是熟悉的工具协议:

  1. 模型请求调用 run_subagent
  2. Harness 解析参数;
  3. 权限和 Hook 生效;
  4. Harness 启动一个隔离的子循环;
  5. 子循环返回压缩结果;
  6. 主 Agent 继续推进 Todo。

这样设计的好处是:前几篇搭出来的 TOOLSTOOL_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_fileedit_filerun_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 整理出来的 taskcontextexpected_output

第二,子 Agent 使用 SUBAGENT_TOOLS,不是主 Agent 的 TOOLS。这保证了工具边界。

第三,max_steps 被限制住了。子 Agent 是短生命周期 worker,不应该无限运行。

第四,最终结果被截断到 12000 字符。真实项目里你可以用更精细的结果压缩策略,但教学版先避免子 Agent 把大段原始内容倒回主上下文。

Subagent 上下文边界

主 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_subagentread_fileedit_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:

  1. run_subagent 把子代理包装成主 Agent 可以调用的工具;
  2. 子 Agent 使用独立 messages,避免污染主上下文;
  3. 子 Agent 使用受限工具集,默认不具备写文件和继续派生子代理的能力;
  4. max_steps 和结果截断控制子 Agent 的生命周期和输出大小;
  5. 让 TodoWrite 负责主线计划,Subagent 负责局部探索和压缩结果;
  6. 继续让权限策略和 Hook 管住 Subagent,而不是绕过 Harness。

到这里,Agent Harness 已经有了一个更完整的工作形态:

  • 主 Agent 维护目标和 Todo;
  • 工具系统提供真实动作;
  • 权限和 Hook 管控工具调用;
  • Subagent 承担局部任务,减少主上下文污染。

下一篇继续看最近很常见的一个概念:Skill。现在很多 Agent 产品都在讲 Skill,但它本质上并不神秘。我们会把 Skill 拆开看:它到底是提示词、工具、文件、流程模板,还是一种按需加载的上下文组织方式。