从零手写多 Agent 编排框架:200 行 Python 跑通 Orchestrator-Worker

从零手写多 Agent 编排框架:200 行 Python 跑通 Orchestrator-Worker

上一篇《从单兵到军团》我把多智能体的概念、三种编排模式、四大治理难题讲了一遍。但光看理论,你心里一定痒:这玩意到底怎么落地? 市面上有 LangGraph、CrewAI 一大堆框架,可如果直接套框架,你永远看不清它内核里"任务怎么拆、消息怎么传、结果怎么汇总"这三件事。所以这篇我不碰任何框架,用 200 行纯 Python 从零手写一个 Orchestrator-Worker 编排框架。写完你会明白:多 Agent 的骨架其实就那么几根梁,框架只是给它刷了层漆。

一、先想清楚:一个多 Agent 框架最少要有几块?

不急着写代码,先回答一个更底层的问题------让多个 Agent 协作,本质需要解决哪几件事?

我把它拆成四块,缺一不可:

  1. 统一的语言 :Agent 之间得说同一种"话",否则一个发 JSON 一个发纯文本,系统立刻乱套。这就是消息契约(Message Schema)
  2. 会干活的个体:职责单一的 Worker,给它一个明确任务,它返回一个明确结果,不多管闲事。
  3. 会调度的大脑:Orchestrator,负责理解总目标、拆成子任务、分发给合适的 Worker、再把结果拼起来。
  4. 能聊天的底座:一个 LLM 调用封装。多 Agent 本质是"多次 LLM 调用 + 一套编排逻辑",LLM 是燃料,编排是发动机。

上面这张图就是这篇要搭的全部。别被"框架"俩字吓到,它翻译成人话就一句:Orchestrator 拆活 → Worker 干活 → 结果沿消息契约回流 → Orchestrator 拼装。下面一块块把它垒起来。

二、第 0 块:一个模型无关的 LLM 封装

多 Agent 框架应该跟模型供应商解耦 。我用 openai 这个兼容库,把 base_urlapi_key 做成参数,这样一会儿跑 Grok、一会儿跑 DeepSeek、一会儿跑 OpenAI,只改一个配置。

python 复制代码
# llm.py ------ 模型无关的调用封装
import os
from openai import OpenAI

class LLMClient:
    """OpenAI 兼容的 LLM 封装,base_url 可切换任意供应商"""
    def __init__(self, base_url, api_key, model):
        self.model = model
        self.client = OpenAI(base_url=base_url, api_key=api_key)

    def chat(self, system: str, user: str, temperature: float = 0.2) -> str:
        resp = self.client.chat.completions.create(
            model=self.model,
            temperature=temperature,
            messages=[
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
        )
        return resp.choices[0].message.content

    @staticmethod
    def default():
        # 这里以 xAI Grok 为例;换成 OpenAI/DeepSeek 只改这两行
        return LLMClient(
            base_url="https://api.x.ai/v1",
            api_key=os.getenv("XAI_API_KEY", "your-key"),
            model="grok-4.5",
        )

要点就一个:chat 函数只认 system + user 两段,别的都不管。这样所有 Agent 共用同一个口子,你后面想接流式、想接缓存,都在这一层改,Agent 代码一行不用动。

三、第 1 块:消息契约,Agent 之间的"通用语"

这是整个框架的基石,也是 90% 的"从零写多 Agent"教程会漏掉的部分。没它,你的系统越复杂越像一锅粥。

python 复制代码
# message.py ------ 统一消息契约
import json
from dataclasses import dataclass, field, asdict
from typing import Any, Optional

@dataclass
class Message:
    """Agent 间传递的唯一消息结构"""
    role: str               # "task" 任务 | "result" 结果 | "question" 追问
    from_agent: str         # 发送方 Agent 名
    to_agent: str           # 接收方;"orchestrator" 表示回主控
    task_id: str            # 归属的任务 id,追踪靠它
    payload: dict           # 结构化业务内容
    meta: dict = field(default_factory=dict)   # 预留:优先级、重试次数等

    def to_json(self) -> str:
        return json.dumps(asdict(self), ensure_ascii=False)

    @staticmethod
    def task(from_agent: str, to_agent: str, task_id: str, instruction: str, **extra):
        return Message("task", from_agent, to_agent, task_id, {"instruction": instruction, **extra})

    @staticmethod
    def result(from_agent: str, task_id: str, output: Any):
        return Message("result", from_agent, "orchestrator", task_id, {"output": output})

为什么非要用一个 Message 包起来?三个理由:

  • 可扩展 :以后要加优先级、加超时、加加密,往 meta 里塞就行,不动结构。
  • 可追踪 :每条消息都带 task_idfrom/to,出了岔子你能顺着它还原整条链路(下一篇专门讲这个)。
  • 可序列化to_json 一下,消息就能落库、能跨进程、能进消息队列。单机多 Agent 和多机分布式多 Agent 之间,就差一个把 to_json 结果丢进 MQ 的动作。

四、第 2 块:Worker,职责单一的"干活的"

Worker 的设计哲学就一条:让它笨一点,只干一件事。不要给 Worker 塞一个万能 prompt,那又退回单 Agent 的老路了。

python 复制代码
# worker.py ------ 职责单一的 Worker
from llm import LLMClient
from message import Message

class Worker:
    """一个职责单一的 Agent,只负责执行分配到的任务"""
    def __init__(self, name: str, role_desc: str, llm: LLMClient):
        self.name = name
        self.llm = llm
        self.system_prompt = (
            f"你是「{name}」,职责:{role_desc}。\n"
            "你会收到一条 JSON 格式的任务消息,请只完成其中的 instruction 描述的任务。\n"
            "只输出最终结果本身,不要寒暄、不要解释过程、不要输出 JSON 包装。"
        )

    def run(self, msg: Message) -> Message:
        """接收任务消息,返回结果消息"""
        reply = self.llm.chat(self.system_prompt, msg.to_json())
        return Message.result(self.name, msg.task_id, reply)

看几个细节,它们不是随便写的:

  • system prompt 里"只输出结果本身":这一步直接决定你后面汇总时干不干净。如果 Worker 输出一堆"好的,我来帮你......"的开场白,Orchestrator 拼结果时会抓狂。
  • run 的入参和出参都是 Message:Worker 不知道也不关心任务从哪来、结果去哪。它是个纯函数式的黑盒,这就是"职责单一"在代码层面的体现。

五、第 3 块:Orchestrator,会调度的大脑

到这里,主控登场。它的职责分两半:上半场拆任务,下半场拼结果。拆任务交给 LLM 自己判断,拼结果也交给 LLM,主控本身只做"分发 + 汇总"的编排动作。

python 复制代码
# orchestrator.py ------ 拆解 + 分发 + 汇总
import json
from llm import LLMClient
from message import Message
from worker import Worker

class Orchestrator:
    def __init__(self, llm: LLMClient, workers: dict[str, Worker]):
        self.llm = llm
        self.workers = workers
        # 把 worker 清单喂给模型,让它在拆任务时就知道能派给谁
        self._roster = "\n".join(f"- {n}: {w.system_prompt.split('职责:')[1].split('。')[0]}"
                                 for n, w in workers.items())

    def _plan(self, goal: str) -> list[dict]:
        """让 LLM 把总目标拆成 (worker, instruction) 列表"""
        sys = (
            "你是任务规划器。把用户目标拆成若干可并行的子任务。\n"
            f"可用 Worker 清单:\n{self._roster}\n"
            "只输出 JSON 数组,每个元素形如 "
            '{"worker": "名字", "instruction": "给该 Worker 的指令"}。'
        )
        raw = self.llm.chat(sys, f"用户目标:{goal}")
        # 兜底:LLM 偶尔会在 JSON 外面包一段话,这里做个容错提取
        return json.loads(self._extract_json(raw))

    def _extract_json(self, raw: str) -> str:
        start, end = raw.find("["), raw.rfind("]")
        return raw[start:end + 1] if start != -1 and end != -1 else raw

    def run(self, goal: str) -> str:
        plan = self._plan(goal)
        # 逐个分发(先串行,下一篇再讲并行和状态管理)
        results = []
        for i, task in enumerate(plan):
            worker = self.workers[task["worker"]]
            task_id = f"{goal[:8]}-{i}"          # 简化版 task_id
            msg = Message.task("orchestrator", worker.name, task_id, task["instruction"])
            result = worker.run(msg)
            results.append({"worker": task["worker"], "output": result.payload["output"]})

        # 汇总:让 LLM 把各 Worker 的结果拼成最终交付物
        return self._synthesize(goal, results)

    def _synthesize(self, goal: str, results: list[dict]) -> str:
        sys = "你是总编。把多个 Worker 的产出整合成一份面向用户的最终答复,条理清晰。"
        return self.llm.chat(sys, f"目标:{goal}\n各 Worker 产出:\n{json.dumps(results, ensure_ascii=False)}")

_plan 里那句 self._roster 特别关键------拆任务不是 Orchestrator 拍脑袋拆,而是看着"手头有哪些人"来拆。你团队里只有"研究员"和"写手",它就不会拆出一个"写代码"的任务。这正是多 Agent 比单 Agent 稳的地方:能力边界是显式声明出来的,而不是藏在 prompt 里。

六、把它们拼起来跑一个真实任务

四块集齐,写个 main.py 验证。用最经典的"研究员 + 写手"双 Worker 团队,让它产出一篇技术短文。

python 复制代码
# main.py ------ 跑通一个「研究员 + 写手」双 Agent 团队
from llm import LLMClient
from worker import Worker
from orchestrator import Orchestrator

llm = LLMClient.default()

# 组队:两个职责单一的 Worker
researcher = Worker("研究员", "负责搜集事实、数据和来源,产出要点清单", llm)
writer = Worker("写手", "负责把要点写成通俗易懂的中文短文", llm)

# 主控接管
boss = Orchestrator(llm, {"研究员": researcher, "写手": writer})

goal = "写一篇 300 字短文,介绍什么是 MCP(Model Context Protocol),面向零基础读者"
print(boss.run(goal))

跑起来你会看到这样一段协作(我把关键流转标出来了):

它内部的完整链路是main 把目标丢给 Orchestrator_plan 让 LLM 拆出「研究员:查 MCP 是什么」和「写手:写 300 字短文」两个子任务 → 逐个派给对应 Worker → 研究员返回要点、写手基于要点成文 → _synthesize 把两份产出拼成最终答案。全程你只写了一句 boss.run(goal)

这就是多 Agent 和"你在 for 循环里调两次 LLM"的本质区别:任务拆解、能力路由、结果整合这三件事,是被显式建模、可观测、可复用的,而不是散落在你的一次性脚本里。

七、这份"最小实现"还缺什么?

坦白讲,现在这套只能算"玩具"。它有明显的窟窿,我故意留着,下一篇一个个补上:

  1. 没有并行:子任务一个接一个跑,慢。研究员和写手明明能并行。
  2. 没有状态task_idgoal[:8] 这种偷懒写法,撞了都不知道;也没有任务看板,哪个完成了、哪个卡住了,黑盒。
  3. 没有容错 :某个 Worker 超时或返回垃圾,整个 run 直接炸,没有重试、没有降级。
  4. 没有成本意识:拆 5 个子任务就调 5 次 LLM,还都是同一个模型,烧钱没有上限。

这四件事,就是**"能跑的 Demo"和"能上线的系统"之间的全部距离**。别急着跳进 LangGraph------它内部也就是把这些事帮你做掉了,你先把窟窿看清,下一篇我带你用代码一个个堵上:并行分发 + 任务看板状态机 + 超时重试降级 + 模型分级预算熔断

八、小结

这一篇我们没碰任何框架,用 200 行 Python 垒出了多 Agent 的骨架:LLM 封装 → 消息契约 → Worker → Orchestrator。核心感悟就一句------

多 Agent 框架拆穿了,就是「一次会调 LLM 的编排逻辑」。真正的难点从来不是模型不够聪明,而是你怎么定义消息契约、怎么显式建模任务拆解、怎么让每个环节可观测。这几件事框架帮不了你,只能你自己想清楚。

下一篇(07)我接着这套代码往下写,给它加上生产级三件套:状态机、分布式追踪、错误降级与成本控制,让这个玩具真正能扛住生产环境的毒打。先把这篇的代码在本地跑通,下一篇我们继续。


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

专栏 :AI Agent 实战笔记

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