多 Agent 系统生产化:状态机、分布式追踪、错误降级、成本控制四件套

多 Agent 系统生产化:状态机、分布式追踪、错误降级、成本控制四件套

上一篇我从零手写了一个 200 行的 Orchestrator-Worker 框架,也老实交代了它有四个窟窿:不能并行、没有状态、没有容错、不控成本 。有人会问:既然 LangGraph 都把这些做掉了,我干嘛还自己造?答案是------你只有亲手把窟窿堵一遍,才真正理解"多 Agent 为什么难",以后不管用哪个框架,一眼就能看出它哪里会坑你。这篇就接着上篇的代码,把这四个窟窿一个个堵上。补完的这套,已经算得上一个能扛住生产环境毒打的迷你编排层了。

一、先给"任务"立个户口:状态机

上篇最偷懒的地方,是 task_id = goal[:8] 这种写法,而且任务跑没跑完、结果在哪,你完全不知道。生产化第一步,是让每个任务都有户口、有状态

python 复制代码
# state.py ------ 任务状态机 + 看板
from dataclasses import dataclass, field
from typing import Any, Optional

class TaskStatus:
    PENDING   = "pending"     # 待分配
    RUNNING   = "running"     # 执行中
    DONE      = "done"        # 成功
    FAILED    = "failed"      # 失败
    DEGRADED  = "degraded"    # 降级完成(部分结果)

@dataclass
class Task:
    id: str
    worker: str
    instruction: str
    status: str = TaskStatus.PENDING
    result: Any = None
    error: str = ""
    trace_id: str = ""
    retries: int = 0

这就是最朴素的状态机:pending → running → done/failed/degraded。别小看这几个字符串------有了它,你的系统从"黑盒"变成了"看板"。任何一个时刻,你都能回答"这个任务到哪一步了、结果是什么、卡了多久"。

二、给每条消息打上"链条":分布式追踪

状态机解决了"单个任务到哪了",但多 Agent 的问题常常是跨任务的 :最终结果错了,你得知道是研究员查错、还是写手写错、还是主控拼错。这就要分布式追踪 ------给每次"用户目标"发一个 trace_id,这条链上所有消息、所有任务都带同一个 id。

改造上篇的 Message,加一行:

python 复制代码
import uuid

# 一次 run 开一条 trace,全程消息共享同一个 trace_id
trace_id = uuid.uuid4().hex

然后 Task.trace_id = trace_idMessage.meta["trace_id"] = trace_id。出问题时,你按 trace_id 一捞,整条链路的来龙去脉全出来了。这也是为什么我上篇坚持用统一 Message 契约------追踪能力是"长"在消息结构上的,不是事后硬加的

三、让 Worker 跑起来:并行分发

状态和追踪就位,先解决最影响体验的"慢"。上篇是串行 for 循环,现在用线程池并行。多 Agent 的子任务天然可并行,收益立竿见影。

python 复制代码
# production.py ------ 并行 + 状态 + 降级 + 成本 的生产级编排器(核心)
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
from llm import LLMClient
from message import Message
from worker import Worker
from state import Task, TaskStatus

class ProductionOrchestrator:
    def __init__(self, llm: LLMClient, workers: dict[str, Worker],
                 backup: dict[str, str] | None = None,
                 max_workers: int = 4, budget_calls: int = 20):
        self.llm = llm
        self.workers = workers
        self.backup = backup or {}          # worker -> 备用 worker 名
        self.max_workers = max_workers
        self.budget_calls = budget_calls    # 熔断预算
        self._calls = 0

    def _charge(self, n: int = 1):
        """计数熔断:超出预算直接抛错,防止烧钱失控"""
        self._calls += n
        if self._calls > self.budget_calls:
            raise RuntimeError(f"超出 LLM 调用预算({self.budget_calls}),已熔断")

    def run(self, goal: str) -> str:
        trace_id = uuid.uuid4().hex
        plan = self._plan(goal)                          # 拆任务
        tasks = [Task(id=f"{trace_id[:8]}-{i}", worker=t["worker"],
                      instruction=t["instruction"], trace_id=trace_id)
                 for i, t in enumerate(plan)]

        # 并行分发,带状态回写
        with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
            futures = {pool.submit(self._execute, t): t for t in tasks}
            for fut in as_completed(futures):
                t = futures[fut]
                try:
                    t.result = fut.result(timeout=60)     # 超时兜底
                    t.status = TaskStatus.DONE
                except Exception as e:
                    t.error = str(e)
                    t.status = TaskStatus.FAILED

        # 失败任务尝试降级
        for t in tasks:
            if t.status == TaskStatus.FAILED:
                t.status, t.result = self._degrade(t)

        return self._synthesize(goal, tasks)

as_completed 的好处是谁先干完先回来,不用等最慢的那个fut.result(timeout=60) 给了每个任务一个 60 秒的硬上限,超时的任务不会拖死整条链路。

四、天塌了怎么办:错误降级

并行只是"跑得快",生产环境真正要命的是某个 Worker 挂了,整个任务不能跟着陪葬。降级策略我做成三级,逐级兜底:

python 复制代码
    def _degrade(self, task: Task):
        # 第一级:重试一次(可能是网络抖动)
        if task.retries < 1:
            task.retries += 1
            try:
                out = self._execute(task)
                return TaskStatus.DONE, out
            except Exception as e:
                task.error = str(e)

        # 第二级:切换备用 Worker(A 挂了切 B)
        backup_name = self.backup.get(task.worker)
        if backup_name and backup_name in self.workers:
            try:
                out = self._execute(task, worker_name=backup_name)
                return TaskStatus.DEGRADED, out   # 标记"降级完成"
            except Exception as e:
                task.error = str(e)

        # 第三级:主控自己兜底(orchestrator 用强模型硬顶)
        fallback = self.llm.chat(
            "你是兜底执行者,尽力完成这个任务。",
            f"任务:{task.instruction}",
        )
        return TaskStatus.DEGRADED, fallback

核心原则一句话:局部的失败不能拖垮全局。宁可给用户一个"降级但可用"的结果,也不要整条链路因为一个 Worker 超时而全盘报错。这也是多 Agent 相比单 Agent 的一个隐藏优势------单 Agent 挂了就是全挂,多 Agent 挂一个还能换一个顶。

五、钱不能白烧:模型分级 + 预算熔断

最后是成本。多 Agent = 多次 LLM 调用,不设防的话,一个需求拆 10 个子任务、每个还重试两轮,token 是单 Agent 的十几倍。两个手段:

手段一:模型分级。 不是所有环节都配用旗舰模型。编排、汇总、兜底用强模型,普通子任务用便宜模型 。上篇我的 Worker 和 Orchestrator 共用一个 llm,这里拆开------Worker 换成更便宜的模型,主控保留旗舰。

python 复制代码
strong_llm = LLMClient(..., model="grok-4.5")   # 编排/汇总/兜底
cheap_llm  = LLMClient(..., model="grok-4.1")   # 普通子任务执行

boss = ProductionOrchestrator(
    llm=strong_llm,
    workers={"研究员": Worker("研究员", ..., cheap_llm),
             "写手":  Worker("写手",  ..., cheap_llm)},
    budget_calls=20,
)

一个工程直觉:90% 的 token 都花在"执行"上,而不是"编排"上。所以省钱的大头是把执行层降配,而不是在编排层抠那两三次调用。

手段二:预算熔断。 上面的 _charge / budget_calls 就是------给每次 run 设一个调用次数上限,超了直接抛错停机。宁可任务失败,也不要失控烧钱。生产上这个预算还会细化到 token 级,但调用次数上限是最容易落地、最有效的第一道闸。

六、整套代码长这样

把上面几段拼起来(加上 _plan_execute_synthesize 三个方法和上篇复用的 llm.py/message.py/worker.py),就是一套能直接跑的生产级编排层。完整文件我放在文末可运行清单里,这里只补最关键的两个方法:

python 复制代码
    def _plan(self, goal: str) -> list[dict]:
        self._charge()
        raw = self.llm.chat(
            f"你是任务规划器,可用 Worker:{self._roster()}。"
            "只输出 JSON 数组:[{\"worker\":..., \"instruction\":...}]",
            goal)
        import json
        return json.loads(self._extract_json(raw))

    def _execute(self, task: Task, worker_name: str | None = None):
        self._charge()
        name = worker_name or task.worker
        msg = Message.task("orchestrator", name, task.id, task.instruction)
        msg.meta["trace_id"] = task.trace_id
        return self.workers[name].run(msg).payload["output"]

    def _synthesize(self, goal: str, tasks: list[Task]) -> str:
        self._charge()
        summary = [{"worker": t.worker, "status": t.status,
                    "output": t.result, "error": t.error} for t in tasks]
        return self.llm.chat(
            "你是总编,整合各 Worker 产出;标注出降级/失败的任务。",
            f"目标:{goal}\n各任务结果:{summary}")

注意 _synthesize 里我让主控把"降级/失败的任务"也标出来------生产系统里,诚实报告"哪个环节降级了"比憋出一个看似完美的答案重要得多。

七、小结

到这儿,上篇的四个窟窿全部堵上了:

窟窿 解法 关键动作
不能并行 ThreadPoolExecutor 子任务并发,谁先完谁先回
没有状态 Task 状态机 pending→running→done/failed/degraded
没有容错 三级降级 重试 → 切备用 → 主控兜底
不控成本 分级 + 熔断 执行层降配 + 调用预算上限

回头看,这四件事没有一件是"模型能力"问题,全是"工程治理"问题 。这也是我对多 Agent 的核心判断:2026 年的分水岭,早就从"模型聪不聪明"移到了"你会不会做分布式系统的治理"

下一篇(08)是这个系列的收尾,也是最反直觉的一篇:我会把中心化编排、任务交接(Swarm)、竞争择优(Tournament)三种模式各写一个最小实现,跑同一个任务对比 ,再讲一件很多人逃避的事------多 Agent 系统到底怎么评测。不是因为模型更强所以更好,而是因为编排对了所以更好,下一篇我们用数据说话。


作者 :夏文强 | OpenHarmony 贡献者,专注 AI Agent 工程化落地

专栏 :AI Agent 实战笔记

觉得有用就点个赞 👍,有疑问欢迎评论区交流~