上个月我们组接到一个需求:把「每天早上自动整理竞品动态」这件事全交给一个 Agent 做。听起来简单,真正动手才发现坑在架构上------单个大模型循环调工具,跑到第五步就开始忘事,工具调错了不回头,失败了整条链子重来。我前后重构了三次,最后落到的结构是「规划-执行-校验」三段式,我叫它 Super-Agent 架构。这篇文章把三段各自的实现、以及我踩过的具体问题完整写出来。
为什么单循环 Agent 一定会崩
先说我第一版的写法,也是最容易想到的写法:一个大 while 循环,模型输出 tool_call,执行,把结果塞回上下文,继续循环,直到模型说「完成」。
python
def naive_agent(task, tools, max_steps=20):
messages = [{"role": "user", "content": task}]
for step in range(max_steps):
resp = llm.chat(messages=messages, tools=tools)
if not resp.tool_calls:
return resp.content
for call in resp.tool_calls:
result = execute(call)
messages.append({"role": "tool", "content": result})
return "超过最大步数"
这个结构在 3 步以内的任务上工作得很好,我一开始还挺得意。但真实任务跑起来,它有三个绕不过去的问题。
问题一:上下文污染导致跑偏。 抓取网页返回的 HTML 动辄几万 token,塞进上下文之后,模型对最初的任务描述注意力急剧下降。我实测过,同样是「整理 5 家竞品的更新」,在上下文里塞过 3 个完整 HTML 之后,模型开始重复抓已经抓过的页面。
问题二:错了不回头。 第 2 步抓错了网站,第 3、4、5 步全在错误数据上继续加工,最后输出一份看起来很认真、内容全错的报告。单循环里没有「这个中间结果不可信,回去重做」的机制。
问题三:无法并行。 抓 5 个网站是天然并行的,但单循环被 for call in resp.tool_calls 串行化了。5 个页面每个 8 秒,串行 40 秒,并行 10 秒,差距是四倍。
第一段:规划器只做一件事------产出可执行的任务图
我把「想清楚要做什么」和「实际去做」彻底拆开。规划器是一个独立的 LLM 调用,输入是任务描述和可用工具清单,输出必须是一份结构化的 DAG,而不是自然语言。
python
from pydantic import BaseModel, Field
from typing import Literal
class Step(BaseModel):
id: str = Field(description="唯一标识,如 s1、s2")
tool: str = Field(description="要调用的工具名,必须在给定清单内")
args: dict = Field(description="工具参数,值可以是 $s1.output 这样的引用")
depends_on: list[str] = Field(default_factory=list)
on_fail: Literal["retry", "skip", "abort"] = "retry"
max_retry: int = 2
class Plan(BaseModel):
goal: str
steps: list[Step]
用 Pydantic 约束输出格式这一步非常关键。我第一版让模型输出 JSON 字符串,然后 json.loads,结果大约 15% 的调用会因为多了个逗号或者用了单引号而解析失败。换成 function calling 强制 schema 之后,解析失败率降到 0。
规划器的 system prompt 我改了很多版,最后稳定下来的核心约束是这三条:
- 每个 step 只能调用一个工具,不允许「先抓取再总结」这种复合描述;
- 依赖必须显式声明,没有依赖关系的步骤视为可并行;
- 参数里引用上游输出必须用
$sN.output语法,不允许写「上一步的结果」。
第三条是我加了很久才明白的。早期我允许模型写自然语言引用,结果执行器根本没法解析,只能把整个上下文丢给下一步,又退回到单循环的老路。
规划器输出的一个真实例子(竞品动态任务):
json
{
"goal": "整理 5 家竞品本周更新",
"steps": [
{"id": "s1", "tool": "fetch_page", "args": {"url": "https://a.com/blog"}, "depends_on": [], "on_fail": "skip"},
{"id": "s2", "tool": "fetch_page", "args": {"url": "https://b.com/changelog"}, "depends_on": [], "on_fail": "skip"},
{"id": "s3", "tool": "fetch_page", "args": {"url": "https://c.com/news"}, "depends_on": [], "on_fail": "skip"},
{"id": "s4", "tool": "extract_updates", "args": {"html": "$s1.output", "since": "7d"}, "depends_on": ["s1"]},
{"id": "s5", "tool": "extract_updates", "args": {"html": "$s2.output", "since": "7d"}, "depends_on": ["s2"]},
{"id": "s6", "tool": "extract_updates", "args": {"html": "$s3.output", "since": "7d"}, "depends_on": ["s3"]},
{"id": "s7", "tool": "merge_report", "args": {"items": "$s4.output,$s5.output,$s6.output"}, "depends_on": ["s4", "s5", "s6"]}
]
}
注意 s1/s2/s3 之间没有依赖,它们可以同时发出去。
第二段:执行器按拓扑序跑,用信号量控制并发
执行器不碰任何 LLM,它只做三件事:解析依赖、并发调度、处理失败。
python
import asyncio
from collections import defaultdict
async def execute_plan(plan: Plan, registry: dict, concurrency: int = 4):
sem = asyncio.Semaphore(concurrency)
results: dict[str, str] = {}
done = asyncio.Event()
pending = {s.id: s for s in plan.steps}
running: dict[str, asyncio.Task] = {}
failed: set[str] = set()
def ready_steps():
return [
s for s in pending.values()
if all(d in results or d in failed for d in s.depends_on)
]
async def run_step(step: Step):
async with sem:
args = resolve_args(step.args, results)
tool = registry[step.tool]
attempt = 0
while True:
try:
return await asyncio.wait_for(tool(**args), timeout=60)
except Exception as exc:
attempt += 1
if step.on_fail == "abort":
raise
if attempt > step.max_retry:
log.warning("step %s failed: %s", step.id, exc)
return None
await asyncio.sleep(2 ** attempt)
while pending or running:
for step in ready_steps():
pending.pop(step.id)
if any(d in failed for d in step.depends_on) and step.on_fail != "skip":
failed.add(step.id)
continue
running[step.id] = asyncio.create_task(run_step(step))
if not running:
break
done_set, _ = await asyncio.wait(
running.values(), return_when=asyncio.FIRST_COMPLETED
)
for task in done_set:
sid = next(k for k, v in running.items() if v is task)
running.pop(sid)
try:
out = task.result()
except Exception:
failed.add(sid)
continue
if out is None:
failed.add(sid)
else:
results[sid] = out
return results, failed
几个实现上的取舍值得说。
并发用 asyncio 而不是线程池。 因为大部分工具是网络 IO(抓页面、调 API),异步天然合适,而且 asyncio.Semaphore 控制并发比线程池直观。但如果你的工具里有 CPU 密集的(比如 PDF 解析),就得用 run_in_executor 包一层,否则会阻塞整个事件循环------我踩过,现象是并发设成 4 但实际串行,排查了一下午。
asyncio.wait_for 的 60 秒超时是硬性的。 之前没有超时,某个页面一直不返回,整个 DAG 卡死。加了超时之后,超时被当作失败处理,走 on_fail 分支。
on_fail 三选一的设计来自实际需求。 抓页面失败应该 skip(少一家竞品也能出报告),合并报告失败必须 abort(没报告等于没干活),而调用 LLM 做提取失败应该 retry(大概率是临时抖动)。让规划器自己决定每个步骤的失败策略,比在代码里写死灵活得多。
指数退避 2 ** attempt。 第一版我用固定 2 秒重试,遇到对方限流(返回 429)时,三次重试全在限流窗口内,白重试。改成 2、4、8 秒之后成功率明显上升。
第三段:校验器是最后一道闸
执行完拿到 results 和 failed,很多人就直接把结果拼一拼返回了。我在这一步加了校验器,它是整个架构里性价比最高的部分。
校验器做三件事:
python
class CheckResult(BaseModel):
passed: bool
reason: str
retry_steps: list[str] = Field(default_factory=list)
async def validate(plan: Plan, results: dict, failed: set) -> CheckResult:
# 1. 结构性检查:关键步骤是否都有产出
critical = {s.id for s in plan.steps if s.on_fail == "abort"}
missing = critical - results.keys()
if missing:
return CheckResult(passed=False, reason=f"关键步骤缺失: {missing}",
retry_steps=list(missing))
# 2. 覆盖率检查:成功步骤占比
ratio = len(results) / len(plan.steps)
if ratio < 0.6:
return CheckResult(passed=False, reason=f"完成率仅 {ratio:.0%}")
# 3. 语义检查:让 LLM 判断产出是否满足原始目标
verdict = await llm.chat_json(
system="你是严格的验收员,只判断产出是否满足目标,不要客气。",
user=f"目标:{plan.goal}\n产出:{summarize(results)}",
schema=CheckResult,
)
return verdict
第 3 步的语义检查是我最纠结的地方,因为它多一次 LLM 调用、多几秒延迟、多一份成本。但实测下来它抓到了两类结构性检查抓不到的问题:
一类是内容跑题 。有次抓取成功了、提取也成功了,但提取出来的全是「关于我们」页面的内容------因为那家竞品把博客改成了 JS 渲染,fetch_page 拿到的是空壳 HTML,提取器在空壳里硬凑出了几段话。结构检查全绿,语义检查一眼看出「这些内容跟产品更新无关」。
另一类是数量异常 。某次只提取到 1 条更新,而历史平均是 8 条。语义检查会指出「产出明显少于预期」,触发对 extract_updates 的重跑。
校验不通过时返回 retry_steps,执行器只重跑这些步骤,而不是整个 DAG 重来。这个增量重跑省了大量时间------早期我图省事直接全量重跑,一个 40 秒的任务变成 80 秒。
三段之间怎么传数据
这是架构里最容易写乱的地方。我的原则是:规划器只传结构,执行器只传数据,校验器只传结论。
具体实现上,results 字典的 value 一律是字符串(或者可 JSON 序列化的结构),绝不传对象。因为一旦传对象,你就得处理序列化、深拷贝、并发修改,很快就失控。抓页面返回 HTML 字符串,提取返回 JSON 字符串,合并返回 Markdown 字符串,每一步的输出都是「可落盘、可日志、可重放」的纯数据。
顺带一个好处:整个 DAG 的执行过程可以完整落盘成一个 JSONL,每行是 {step_id, tool, args, output, elapsed, status}。出问题时把这个文件丢给模型,它能自己分析出哪一步出了问题------这比让它读一堆日志高效得多。
实测数据
同一个任务(5 家竞品、7 天更新),三种架构的对比,各跑 10 次取平均:
| 指标 | 单循环 Agent | 三段式 Super-Agent |
|---|---|---|
| 平均耗时 | 47.3 秒 | 14.8 秒 |
| 成功率 | 6/10 | 9/10 |
| 单次 token 消耗 | 约 38k | 约 21k |
| 出错后可定位 | 靠读日志猜 | 直接看 JSONL |
耗时降了 68%,主要来自并行;token 降了 45%,因为上下文里不再堆原始 HTML,每个步骤只拿到自己需要的那一段。
三个我建议你提前避开的坑
坑一:规划器会规划出环。 虽然 prompt 里写了「不能有循环依赖」,但模型偶尔还是会产出 s1 depends_on s2, s2 depends_on s1。执行器里必须有一道检测:如果一轮循环下来 ready_steps() 为空但 pending 非空,说明有环,立刻报错退出,别死等。我加这个检测之前,程序卡死过两次。
坑二:工具描述写得太随意,规划器就会乱用。 我的 fetch_page 一开始描述是「抓取网页」,结果模型拿它去抓需要登录的页面,抓回来一个登录页还当成正常结果。后来我把描述改成「抓取公开可访问的静态网页,遇到登录页或 JS 渲染页面会返回空内容,此时应改用 fetch_rendered」,误用率从 30% 降到 5%。工具描述就是给模型看的 API 文档,得认真写。
坑三:并发数不是越大越好。 我一开始设成 10,结果目标站点直接给我返回 429,然后所有步骤都进重试,反而更慢。4 是个比较稳的值,配合指数退避,实测比 10 并发快。
这套架构现在跑在我们两个内部流程上,一个竞品监控、一个周报生成,稳定运行一个多月。它谈不上多先进,本质上就是把「想、做、查」三件事分开、各自做扎实。但就这一点分离,让整个系统从「跑通了但不敢用」变成了「跑挂了也能自己修」。