苦猿的大模型日记 · Day56 · 从0学习Claude Code(六)Subagent-帮普通人把AI学进简历系列

前言:第六篇,给 Agent 造一个"分身"

上一篇,咱们给 Agent 发了张工单:动手之前先列清单,做一项勾一项,清单本身回流进上下文。从此它干活不跑偏了——做到第八步还记得第一步要干嘛。

但有个成本,从第一篇到现在一直没人算过:上下文本身

Agent 每读一个文件、每跑一条命令,工具结果都会留在 messages[] 里,一轮不落。你让它查一个"这个项目用什么测试框架",它读了十个文件才弄明白——那这十个文件的全文,就十条不差地躺在上下文里,直到对话结束。

问题弄清之后,这些中间过程就是占着注意力不干活的数据。而且上下文这东西和硬盘不一样:硬盘存进去就完事了,上下文里的每一个 token,后续每一轮都会被模型重新看一遍。它不是仓库,它是你桌面上一直摊着不收的草稿纸。

这一篇就干一件事:给 Agent 造一个分身——一个叫 task 的工具。主 Agent 遇到调查类的活,派一个子 Agent 出去,给它一张白纸(全新的 messages[]),让它去翻文件、跑命令、把事情查清楚。干完之后,只把结论带回来。中间那几十条工具调用,一条都不进主上下文。

读完你会拿到三样东西:

  1. run_subagent 的完整实现:一个嵌套的 agent 循环,总共三十来行——你会看到它就是主循环的复刻,唯一的区别是消息列表是新的
  2. 一张边界决策表:哪些东西隔离(消息)、哪些东西共享(进程、工作区、权限、Hooks)——每一行都有为什么
  3. 两个最容易想错的点:subagent 不是沙箱(它能删你的文件);委派只有一层(子 Agent 没有 task 工具,套不了娃)

门槛不变:会 Python 基础语法、有上一篇跑通的那份带清单的代码。直接开始。


PART 01:案发现场——查一个问题,上下文胖了十倍

先复原现场。我给之前的 Agent 出过一道很日常的题:

这个项目用什么测试框架?测试怎么跑?帮我确认一下,待会儿改完代码我要跑。

这题不难,甚至可以说平淡。它的执行过程也挑不出毛病:


> glob("**/test*")
> read_file("pyproject.toml")
> read_file("tests/conftest.py")
> read_file("tests/test_agent.py")
> bash("python -m pytest --collect-only -q")

扫一眼有哪些测试文件,看看依赖声明,读一下 conftest 和一个样例测试,最后空跑一次收集。教科书式的调查流程,最后给我的答案也正确:"pytest,直接 python -m pytest 跑,conftest 里有 fixtures"。

但你低头看一眼 messages[],就是另一副画面了。

五个工具结果,全文躺在里面:pyproject.toml 的完整依赖列表、conftest.py 的四十行代码、test_agent.py 的八十行代码、pytest 的收集输出……加起来好几千 token。而这一切换来的结论,就一行字。

images/01-context-bloat-compare.png

来算笔账。上下文是按 token 计费的注意力不动产。那几千 token 的文件内容,从它们进入 messages[] 的那一刻起,模型后续每一轮生成都要重新读一遍它们——哪怕这之后的对话聊的是改代码、跑测试、修 bug,跟 conftest.py 没有半毛钱关系。

相当于你查完资料,把十页草稿纸整页订进了作业本,然后每写一行作业都把这十页从头翻一遍

更要命的是这个模式会累积。修一个 bug 读八个文件,追一个调用链读十二个文件,一天下来,Agent 的上下文里 80% 是"已经用完的调查过程"。真正干活的指令和结论,被自己产生的垃圾数据淹着。

直觉上的解法是把读过的内容删掉?不行。messages[] 是对话的连续性本身,砍中间任何一条,API 直接报错——工具调用和结果必须成对出现。这条路上没有"删除"这个选项。

那就只剩一条路:这些中间过程,从一开始就别进主上下文

怎么做到?派个分身去干。


PART 02:实现——三十行,一个带白纸出去干活的分身

思路说出来你会觉得朴素得过分:既然主 Agent 的本事就是"一个循环 + 一组工具",那子 Agent 也照办一个就是了——同样的循环,同样的工具,唯一区别是消息列表从一张白纸开始

然后把"派分身"这件事本身,包装成一个普通工具:task。主 Agent 调用它,跟调用 read_file 没有任何区别。

先看工具定义,短得不像话:


TASK_TOOL = {
    "name": "task",
    "description": "Run a subagent with fresh conversation context and return its final text.",
    "input_schema": {
        "type": "object",
        "properties": {"prompt": {"type": "string", "minLength": 1}},
        "required": ["prompt"],
    },
}

TOOLS = [*BASE_TOOLS, TASK_TOOL]
TOOL_HANDLERS = {**BASE_HANDLERS, "task": run_subagent}

一个参数 prompt:你要分身去干的那个子任务。注册方式你也熟了——工具分发那套 handler map,加一行的事。

真正的心脏是 run_subagent。建议你把它和第一篇的主循环并排开着看,我先把完整代码贴出来:


SUB_TOOLS = list(BASE_TOOLS)      # 注意:里面没有 task
SUB_HANDLERS = dict(BASE_HANDLERS)


def extract_text(content) -> str:
    if not isinstance(content, list):
        return str(content)
    return "\n".join(
        getattr(block, "text", "")
        for block in content
        if getattr(block, "type", None) == "text"
    )


def run_subagent(prompt: str) -> str:
    print("\n\033[35m[Subagent started]\033[0m")
    messages = [{"role": "user", "content": prompt}]     # 一张白纸

    for _ in range(30):
        response = client.messages.create(
            model=MODEL,
            system=SUB_SYSTEM,
            messages=messages,          # 注意:是子循环自己的 messages
            tools=SUB_TOOLS,
            max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        tool_calls = [
            block for block in response.content if block.type == "tool_use"
        ]
        if not tool_calls:                               # 没有工具调用了 = 干完了
            print("\033[35m[Subagent done]\033[0m")
            return extract_text(response.content) or "(no summary)"

        results = []
        for block in tool_calls:
            output = execute_tool(block, SUB_HANDLERS)
            print(f"  \033[90m[sub] {block.name}: {output[:100]}\033[0m")
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })
        messages.append({"role": "user", "content": results})

    print("\033[35m[Subagent stopped]\033[0m")
    return "Subagent stopped after 30 turns without a final answer."

逐个拆关键点。

第一,白纸从哪来。 messages = [{"role": "user", "content": prompt}]——子 Agent 的全部初始上下文,就是父 Agent 在 task 参数里写的那段任务描述。它不知道你和主 Agent 之前聊了什么,不知道全局目标,不知道光荣历史。它眼里只有这一件事。

第二,循环是复刻的。 调模型、取 tool_use、执行工具、结果回填——和主循环一模一样的四步。所以这篇的代码量才这么小:分身的"身体"你已经会造了,这里只是让它再长一个。

第三,也是全篇的题眼:return 主循环没有返回值,它是会话本身;而 run_subagent 是个函数,它的返回值会变成父上下文里的 tool_result。看收尾那个分支——当模型这轮不再调用任何工具,说明它认为任务完成了,此时 extract_text 把回复里的文本块抽出来,return

这一行 return,就是分身和主上下文之间唯一的通道。子 Agent 那边翻江倒海读了多少文件,父这边看到的只有这个函数吐回来的最终文本。中间的 messages 是函数局部变量——函数一结束,它就没了。草稿纸用完即焚,作业本上只有结论。

跑起来看输出。主 Agent 收到"查一下测试框架"的指令后,决定派分身:


[Subagent started]
  [sub] glob: tests/test_agent.py
  [sub] glob: tests/test_tools.py
  [sub] read_file: [tool]
    build-system requires = ["setuptools>=68"]
    dependencies = [
    ...
  [sub] read_file: import pytest
  [sub] bash: 12 tests collected in 0.42s
[Subagent done]

带 [sub] 前缀的是子 Agent 的动作日志——注意这些日志只是打印给你看的,不进任何上下文。分身翻了五个文件、跑了一条命令,全程发生在它自己的白纸上。

然后主 Agent 那边,messages[] 里只多了一条:


tool_result: "该项目使用 pytest 作为测试框架。
测试位于 tests/ 目录,直接运行 python -m pytest 即可。
conftest.py 提供了公共 fixtures。"

三行结论。PART 01 里那几千 token 的中间过程,一条没进来。

images/02-subagent-flow.png


PART 03:边界——隔离的是消息,不是你以为的一切

到这里,很多人脑子里会自动补出一个画面:subagent = 沙箱。一个隔离的小盒子,里面随便造,外面毫发无伤。

错得离谱。 这是本篇第一个要砸掉的误解。

一句话记住真实边界:隔离的是消息,不是进程和文件系统。 父子两个循环跑在同一个 Python 进程里,共享同一个 WORKDIR——子 Agent 写文件、跑命令,改的就是你眼前这个工作区。它删了的文件就是没了,它装的依赖就是装上了。

把所有边界决策列成一张表,每一行都值得单独说:

决策选择原因
对话全新的 messages[]不把父对话复制给子 Agent
执行同一进程、同一 WORKDIR两个循环都能看到文件系统的修改
返回值只回最终文本中间工具调用不进父上下文
委派深度子 Agent 没有 task 工具只允许一层委派
安全共享 Hooks 和权限父子循环过同一道门禁

第一行,对话隔离。 有人会问:为什么不把父对话也给它一份,让它"了解背景"?因为委派的定义就是自包含的子任务——需要背景才能干的活,就不该委派。你让实习生去查个资料,会先把你这半年的会议记录给他看一遍吗?

第二行,执行共享。 这不是偷懒,是特性。想想 task 的典型用途:"去调查 X,然后……"——子 Agent 查清楚,主 Agent 接着根据结论改代码。如果两边工作区隔离,子 Agent 写的文件、主 Agent 就看不见了,委派即失联。委派的是调查,不是流放。

第四行,单层委派,这是最有意思的一行。 看代码就明白:


SUB_TOOLS = list(BASE_TOOLS)      # 只有五个基础工具

SUB_TOOLS 是从 BASE_TOOLS 复制的,而 task 工具只注册进了父级的 TOOL_HANDLERS。所以子 Agent 的工具箱里没有 task——它想再派一个孙 Agent?没这个工具,门都没有。

为什么禁止套娃?两个原因。其一是爆炸:如果每层都能委派,一个任务派三个子任务、每个子任务再派三个,几轮下来就是指数级的循环在跑。其二更本质——委派的动机在子层已经不存在了。我们为什么要派分身?因为主上下文装不下中间过程。而子 Agent 的白纸本来就是空的,它自己的中间过程在自己的纸上写得下,没有任何理由再往下派。

第五行,安全共享,这是最容易被忽略也最要命的一行。 子 Agent 的工具调用走的是同一个 execute_tool——还记得 hooks 那篇吗?权限检查挂在工具执行的入口上。所以子 Agent 想跑 rm -rf /,一样被 deny list 当场拦截;想读写工作区外的路径,一样弹审批。门禁认的是工具调用本身,不认调用者是爹是儿子。

这一点配上"进程共享",边界就完整了:子 Agent 删你的文件(所以需要门禁),但它不记得你聊过什么(所以上下文干净)。能力上是自己人,记忆上是外人。

images/03-boundary-wall.png


PART 04:实录——让分身干活,然后亲手验证它没吹牛

光看结构不过瘾,跑一单完整的。源仓库的教程里给了三道练习题,最妙的是第三道:

用 task 创建一个 string_tools.py,里面写一个 slugify(text) 函数,然后从父 Agent 验证子 Agent 的成果。

妙就妙在"从父验证"这四个字——它把"共享工作区"和"消息隔离"两件事在同一个任务里演全了。我照着跑了一遍:


s06 >> 用 task 派一个子任务:在 s06_subagent/example/ 下创建
string_tools.py,实现 slugify(text: str) -> str。完成后你亲自验证它可用。

[Subagent started]
  [sub] write_file: Wrote 412 bytes to s06_subagent/example/string_tools.py
  [sub] bash: <no output>
[Subagent done]

> bash("python -c \"from s06_subagent.example.string_tools import slugify;
         print(slugify('Hello, 苦猿 World! 56'))\"")
hello-ku-yuan-world-56

看这个流程:分身领了任务,写了文件(写进的是共享工作区),跑了下自测,交回结论。然后主 Agent 自己跑了一条 import 验证——注意这条验证命令是主循环发的,走的是父上下文。

如果工作区是隔离的,主 Agent 这一步会直接 ModuleNotFoundError。它验证通过,恰恰证明了那条边界:文件是共享的,消息是隔离的。分身写了什么,爹看得见;分身怎么想的,爹不知道,也不需要知道。

上下文账单也顺便对了: 分身那次执行在白纸上烧掉的 token(写文件、自测),父上下文一个都没付;父上下文为这次委派付出的,只有 task 的参数(一段任务描述)和返回的结论(两行)。

不过先别急着把所有活都派出去,分身不是免费的

  • 信息成本:白纸意味着它没有父对话背景。task 的 prompt 必须把子任务本身写完整——"查一下测试框架"行,"继续刚才那个事"不行,它不知道刚才是什么
  • 延迟成本run_subagent 是同步的,分身在外面翻文件的每一秒,父循环都在干等
  • 适配成本:一步能干完的小事(读一个文件、跑一条命令)派分身,纯属杀鸡用牛刀——还慢

所以什么时候派?调查类(要的是答案,不要过程)、批量读(要看一堆文件提炼要点)、自包含子任务(写个独立模块、跑个验证)。什么时候不派?主线任务本身、需要上下文背景的决策、两步以内的小事。

最后把六个模块连线收一笔大账:心脏(agent 循环)、双手(工具分发)、神经(权限系统)、挂钩(hooks 扩展)、工单(TodoWrite)、今天的分身(subagent)。

前五个模块解决的都是"自己怎么把活干好"——干得动、干得安全、干得不忘。而分身第一次把矛头对准了一个新东西:上下文里到底该装什么


结尾:工作台上只放结论

回到开头那张塞满草稿纸的桌子。问题的根源从来不是"Agent 读太多文件"——调查本来就是干活的正当部分。错的是记账方式:把过程和结论记在了同一本账上。

subagent 修的就是这本账。调查的过程发生在别处,用完即焚;主上下文这个最贵的工作台上,只留结论。

这大概是上下文管理的第一性原理:

上下文的价值不在装了多少,而在留了多少注意力给真正重要的事。

互动时间:你见过 Agent 上下文被工具输出灌爆的名场面吗?是读了一堆日志找 bug,还是 grep 出了几千行结果?评论区聊聊——顺便说说,你会把什么活派给分身、什么活坚决留在主线上?


下一篇预告:「从0学习 Claude Code」第七篇——Skill Loading,技能加载。分身解决了"过程去哪",但还有一类东西在撑爆上下文:知识。改前端组件要知道 React 规范,写 SQL 要知道表结构,这些说明文档全塞进 system prompt,上下文直接爆掉。下一篇让技能按需注入——用到的时候才加载,和读文件一样自然。

— END —

苦猿 · 帮普通人把 AI 学进简历

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐