从 0 到 1 上手 Super-Agent 架构:任务拆解、工具路由与失败回滚的三段实现

上个月我们组接到一个需求:把「每天早上自动整理竞品动态」这件事全交给一个 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 我改了很多版,最后稳定下来的核心约束是这三条:

  1. 每个 step 只能调用一个工具,不允许「先抓取再总结」这种复合描述;
  2. 依赖必须显式声明,没有依赖关系的步骤视为可并行;
  3. 参数里引用上游输出必须用 $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 并发快。

这套架构现在跑在我们两个内部流程上,一个竞品监控、一个周报生成,稳定运行一个多月。它谈不上多先进,本质上就是把「想、做、查」三件事分开、各自做扎实。但就这一点分离,让整个系统从「跑通了但不敢用」变成了「跑挂了也能自己修」。

相关推荐
2026小鱼AI1 小时前
当 LLM 生成的教学设计“不合格”:结构化校验、局部重生成与并发下的质量漂移
前端
疯华整猫1 小时前
借助 Trae 复刻百度网盘“无权限摇一摇”检测:Android 传感器 AI 辅助实战
前端
deli0071 小时前
流式输出为什么一段一段蹦?拆开 SSE 分块实测 20094 个乱码
前端
857god1 小时前
做了个匿名聊天站,我把 7 个前端坑踩了一遍
前端
deli0071 小时前
GIF 太大怎么压?调帧率、尺寸、颜色数,10 组参数体积对照实测
前端
Momo__2 小时前
你写的 import 重试,从来没生效过
前端·javascript·typescript
鶴哥只手遮天2 小时前
Angular 22 源码级深度解析-第 13 章 依赖注入(一):Injector 抽象、`R3Injector` 与 bloom filter
前端·开源·angular.js
青烟小生2 小时前
前端 AI Agent 编排引擎:从 ReAct 循环到生成式 UI(完整版)
前端
MetWeave2 小时前
一朵玫瑰,装下一年的风(《风不是一个数》系列第 5 篇
前端·数据可视化