手写 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
↓
最终回答给用户
四个关键设计:
- 主循环无上限:靠
finish_reason != "tool_calls"退出,主动权在 LLM - 子循环有硬上限(30 次):因为它是被主 Agent 调用的,必须有保护阀防止死循环
- 子 Agent 上下文隔离:每次新建
sub_messages,只有一句 prompt,不继承主对话历史 - 同步阻塞:主 Agent 调
run_subagent时等待子 Agent 完全结束,不支持并行
工具系统:LLM 能调用的四个基础工具
所有 Agent 共用的基础工具通过 OpenAI 的工具调用协议定义:
| 工具名 | 功能 | 参数 |
|---|---|---|
bash | 运行 shell 命令 | command |
read_file | 读取文件内容 | path、limit(可选) |
write_file | 写入文件 | path、content |
edit_file | 替换文件中的文本 | path、old_text、new_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 的作用:每个工具的处理逻辑都很短,用 lambda 比 def 更紧凑。它相当于 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_calls | LLM 要求调用工具 | 继续循环,执行工具 |
stop | 正常结束 | 打印回复,退出 |
length | 达到 max_tokens 上限 | 提示截断,退出 |
content_filter | 被安全策略拦截 | 提示换问法,退出 |
工具调用协议(OpenAI 格式)
- LLM 返回
finish_reason = "tool_calls",附带msg.tool_calls数组 - 每个
tool_call有唯一的id、function.name、function.arguments(JSON 字符串) - 代码执行工具,构造
{"role": "tool", "tool_call_id": 原id, "content": 字符串} - 把结果 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_calls、tool 结果全部丢弃,只把最终文本带回主 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 更紧凑 |
Path | WORKDIR / 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、流式日志、中断恢复等工程能力,手写维护成本太高。