手写 Sub-Agent:两层嵌套循环实现主从 Agent 协作

0 阅读10分钟

手写 Sub-Agent:两层嵌套循环实现主从 Agent 协作

摘要:Sub-Agent 不是框架的专属能力。本文从零拆解一个 Python 手写实现,用两层嵌套的 ReAct 循环实现主从协作——主 Agent 负责分活,子 Agent 负责干活,上下文隔离、工具白名单、退出信号全部手写落地。

📑 目录

  • 为什么需要 Sub-Agent?—— 单 Agent 的三个瓶颈
  • 整体架构:两层嵌套的 ReAct Loop
  • 工具系统:LLM 能调用的四个基础工具
  • 沙箱隔离:safe_path 与工作区边界
  • 主循环:主动权在 LLM 手里的无限循环
  • 子循环:硬性 30 轮上限的保护阀
  • Python 特性落地:**kw、lambda、Path 与 model_dump
  • 工程上的三个层次:从能跑到可定位
  • 与 LangGraph 的关系:手写版本就是原语实现
  • 互动讨论

为什么需要 Sub-Agent?—— 单 Agent 的三个瓶颈

一个人创业,既当产品、又写代码、还做客服,短期能撑,但很快就会上下文爆炸、顾此失彼。多 Agent 就是开公司——有主管、有研究员、有程序员、有审核员,各司其职。

单 Agent 的核心瓶颈有三个:

瓶颈单 Agent 的问题Sub-Agent 的解法
上下文窗口所有历史、工具结果、中间推理全塞一个 prompt,很快爆每个子 Agent 只维护自己相关的上下文
工具过载给一个 Agent 挂 20 个工具,选择困难,容易调错每个子 Agent 只挂自己领域的基础工具
注意力稀释一个 prompt 里塞了 20 轮历史、5 个工具结果、3 个不同角色指令,注意力被稀释子 Agent 的 prompt 极短,注意力集中

质量提升的真正来源:上下文越干净,LLM 的注意力越集中。不是"人多力量大",而是"每个人只关注自己那一小块,脑子更清醒"。

整体架构:两层嵌套的 ReAct Loop

这个程序是一个主从 Agent 系统,核心结构是两层相同的"推理-行动"循环嵌套:

text

用户输入
   ↓
[主 Agent Loop] ← 无限循环,由 LLM 决定何时停
   ├─ 调普通工具 (bash / read_file / write_file / edit_file)
   └─ 调 task 工具 → [子 Agent Loop] ← 硬性 30 次上限
                          ├─ 调基础工具
                          └─ return 字符串给主 Agent
   ↓
最终回答给用户

四个关键设计:

  1. 主循环无上限:靠 finish_reason != "tool_calls" 退出,主动权在 LLM
  2. 子循环有硬上限(30 次):因为它是被主 Agent 调用的,必须有保护阀防止死循环
  3. 子 Agent 上下文隔离:每次新建 sub_messages,只有一句 prompt,不继承主对话历史
  4. 同步阻塞:主 Agent 调 run_subagent 时等待子 Agent 完全结束,不支持并行

工具系统:LLM 能调用的四个基础工具

所有 Agent 共用的基础工具通过 OpenAI 的工具调用协议定义:

工具名功能参数
bash运行 shell 命令command
read_file读取文件内容pathlimit(可选)
write_file写入文件pathcontent
edit_file替换文件中的文本pathold_textnew_text

工具处理器的映射

工具名和执行函数通过字典映射,用 lambda 做适配层:

python

TOOL_HANDLERS = {
    "bash": lambda **kw: run_bash(kw["command"]),
    "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"])
}

**kw 的作用:打通 LLM 返回的松散 args 和函数签名之间的桥。LLM 返回的 arguments 是一个 JSON 字符串,解析后变成字典,通过 **kw 展开成关键字参数传给 lambda

lambda 的作用:每个工具的处理逻辑都很短,用 lambdadef 更紧凑。它相当于 JavaScript 中的箭头函数。

工具白名单:防止无限套娃

python

# 子 Agent 只有 4 个基础工具
CHILD_TOOLS = [bash, read_file, write_file, edit_file]

# 主 Agent 多了 task 工具
PARENT_TOOLS = CHILD_TOOLS + [task]

关键设计:主 Agent 有 task,子 Agent 没有。防止子 Agent 递归调用自己形成无限套娃

沙箱隔离:safe_path 与工作区边界

所有文件操作都通过 safe_path 校验,防止路径逃逸出工作目录:

python

def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

Path 的运算符重载WORKDIR / p 不是除法,而是 pathlib.Path 特有的路径拼接运算符,相当于 path.join(p)(Node.js)。这是 Python"简洁"原则的体现。

.resolve() + .is_relative_to() :先解析为绝对路径,再判断是否在工作目录内。任何试图用 ../../etc/passwd 逃逸的操作都会被拦截。

bash 工具的黑名单

python

def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"
    # ...

any() 是 Python 内置函数,只要有一个满足就返回真。这一行完成了"黑名单检查"。

主循环:主动权在 LLM 手里的无限循环

主循环的代码结构:

python

def agent_loop(messages: list):
    while True:
        response = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "system", "content": SYSTEM}] + messages,
            tools=PARENT_TOOLS,
            max_tokens=8000
        )
        msg = response.choices[0].message
        messages.append(msg.model_dump())
        finish_reason = response.choices[0].finish_reason

        if finish_reason != "tool_calls":
            # 处理不同的结束原因
            return

        # 执行工具调用
        results = []
        for tool_call in msg.tool_calls:
            # ...
        messages.extend(results)

退出信号:finish_reason

LLM 返回的 finish_reason 决定了循环是否继续:

finish_reason含义处理
tool_callsLLM 要求调用工具继续循环,执行工具
stop正常结束打印回复,退出
length达到 max_tokens 上限提示截断,退出
content_filter被安全策略拦截提示换问法,退出

工具调用协议(OpenAI 格式)

  1. LLM 返回 finish_reason = "tool_calls",附带 msg.tool_calls 数组
  2. 每个 tool_call 有唯一的 idfunction.namefunction.arguments(JSON 字符串)
  3. 代码执行工具,构造 {"role": "tool", "tool_call_id": 原id, "content": 字符串}
  4. 把结果 extend 进 messages,下一轮 LLM 就能看到

tool_call_id 必须严格对应:LLM 内部用它把"我要求调用的工具"和"你返回的结果"配对。ID 缺失或不匹配,API 直接报错。

task 工具:主 Agent 分活给子 Agent

python

if func.name == "task":
    desc = args.get("description", "subtask")
    prompt = args.get('prompt', "")
    print(f"> task({desc}): {prompt[:80]}")
    output = run_subagent(prompt)
else:
    handler = TOOL_HANDLERS.get(func.name)
    output = handler(**args) if handler else f"Unknown tool: {func.name}"

主 Agent 也可以自己完成任务——工具列表也给了主 Agent。如果任务很简单,没必要增加开销去开启子 Agent。

子循环:硬性 30 轮上限的保护阀

子 Agent 的循环结构:

python

def run_subagent(prompt: str) -> str:
    sub_messages = [{"role": "user", "content": prompt}]
    for _ in range(30):
        response = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "system", "content": SUB_SYSTEM}] + sub_messages,
            tools=CHILD_TOOLS,
            max_tokens=8000
        )
        msg = response.choices[0].message
        sub_messages.append(msg.model_dump())

        if response.choices[0].finish_reason != "tool_calls":
            return msg.content or "(no summary)"
        # ... 执行工具
    return "[subagent failed] 超过 30 轮工具调用仍未得出结论"

三个关键设计

1. 上下文隔离

sub_messages 是全新的列表,只有一句 prompt,不继承主对话历史。中间的 tool_callstool 结果全部丢弃,只把最终文本带回主 Agent。这是上下文隔离的核心收益。

2. 硬性上限 30 轮

子循环是被主 Agent 调用的,必须有保护阀防止死循环。超过 30 轮没得出结论,返回明确的失败信息,让主 Agent 换策略。

3. 用代码位置表达状态

正常结束在循环内 return,异常结束在循环外 return。不需要额外的成功/失败标记变量。

for _ in range(30) 中的 _

下标表示这里循环变量用不到,用 _ 占位不会导致语法错误。这是 Python 的惯用写法。

Python 特性落地:**kw、lambda、Path 与 model_dump

这份代码是 Python 特性的密集展示:

特性用法作用
**kw函数定义收集关键字参数 / 调用时展开字典打通 LLM 返回的松散 args 和函数签名之间的桥
lambda字典值存匿名函数每个工具处理逻辑短,比 def 更紧凑
PathWORKDIR / p 重载运算符拼接路径替代 os.path.join,自动处理平台差异
safe_path.resolve() + .is_relative_to()沙箱隔离,防止路径逃逸
any()any(d in command for d in dangerous)一行完成黑名单检查
model_dump()Pydantic 对象转纯字典下次发请求需要原始 dict,不是 Pydantic 对象
__name__ == "__main__"隔离交互入口文件既能当脚本跑,也能被别的文件导入复用
隐式字符串拼接"a" "b" 自动拼接在括号里连续放多个字符串字面量,自动拼接
ANSI 转义input("\001\033[36m\002...")有色提示符 + Readline 正确计算光标宽度

隐式字符串拼接

python

SYSTEM = (
    f"You are a coding agent at {WORKDIR}."
    "Use task for focused exploration or a self-contained subtask."
)

两个字符串字面量连在一起,Python 会自动拼接成一个字符串。不需要 +,

model_dump 的必要性

LLM 返回的 msg 是一个 Pydantic 对象,但下一次发请求时需要原始 dict。model_dump() 把 Pydantic 对象转成纯字典,再 append 到 messages 列表中。

工程上的三个层次:从能跑到可定位

层次特征表现
能跑功能对但边界处理粗糙最初版本
可观测区分 stop / length / content_filter用户能看到失败原因,不会盲目重试
可定位子循环正常结束在循环内 return,异常结束在循环外 return用代码位置表达状态,不需要额外标记变量

几个容易忽视的设计决策

  • System prompt 不进入 messages:每次请求临时拼 [system] + messages,避免污染历史
  • 子 Agent 只返回最终文本:中间的 tool_calls、tool 结果全部丢弃,只把结论带回主 Agent
  • 工具白名单:主 Agent 有 task,子 Agent 没有
  • max_tokens = 8000:硬性截断保护,配合主循环对 length 的处理,形成完整的兜底链

与 LangGraph 的关系:手写版本就是原语实现

你手写的这两层循环,就是 LangGraph 里 create_react_agent原语实现

LangGraph 封装手写版本对应
StateGraph手写的 while True 循环
ToolNode手写的 for tool_call in msg.tool_calls 分支
Checkpointer手动维护的 messages 列表
interrupt主循环里"等用户输入"的 input()

理解了手写版本,再看 LangGraph 的任何 API 都能看穿它在做什么。

互动讨论

💬 子 Agent 为什么必须设置轮次上限?

子 Agent 是被主 Agent 调用的,如果没有上限,一旦子 Agent 陷入死循环,主 Agent 也会被永久阻塞。30 轮是一个工程上的保护阀,超过后返回明确失败信息,让主 Agent 换策略。

💬 主 Agent 和子 Agent 的工具集为什么不一样?

主 Agent 有 task 工具(用来派发子任务),子 Agent 没有。这是为了防止子 Agent 递归调用自己形成无限套娃。同时子 Agent 只挂基础工具,注意力更集中。

💬 子 Agent 的上下文隔离是怎么实现的?

每次调用 run_subagent 时新建一个 sub_messages 列表,只有一句 prompt。中间的 tool_calls 和 tool 结果全部丢弃,只把最终文本带回主 Agent。这样主 Agent 的上下文不会被任务细节污染。

💬 finish_reason 为什么能作为循环退出信号?

LLM 每次返回都会带 finish_reason 字段。tool_calls 表示还需要调用工具(继续循环),其他值表示任务已结束(退出)。这是 OpenAI 协议的标准字段,所有兼容接口都支持。

💬 手写版本和 LangGraph 该选哪个?

学习阶段推荐先手写一遍——理解"状态共享 + 条件路由 + 循环终止"这三件事后,再看 LangGraph 的 API 会非常清晰。生产环境推荐 LangGraph——它提供了 checkpointer、流式日志、中断恢复等工程能力,手写维护成本太高。