Day 04 / 共 20 天 · 第 1 周 建立心智

Supervisor 模式与多 Agent 协作

昨天(Day 03)你会读单个 Agent 的流程图了;今天往上一层:一个任务来了,谁来干?多个 Agent / 多个专家怎么配合?今天不只讲概念——我们把本框架里两个真实的 supervisor 打开,逐行读它们的源码:状态怎么定义、循环怎么防死、决策怎么被校验、失败怎么优雅降级。这是 Day 05 解剖真实 Agent 前必须打牢的一块地基。

📍 你在 20 天里的位置(第 1 周:建立心智)
D01 全景架构 D02 跑起来 D03 LangGraph D04 Supervisor D05 Agent 解剖
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) Supervisor 就像装修工地的领班/工头:他自己不砌墙、不刷漆,只干三件事——接活、判断这道活该派给哪个工种师傅、收结果;师傅之间不越级私聊,全走工头。本框架里有两个"工头"别搞混:① 图内 supervisor_node = 一户装修内部的施工领班(真派真监工,活是它带着干的);② 跨 agent router = 物业大厅的问询导览员(你说句人话,它只告诉你"该去几号窗口找哪位",绝不替你去办)。记住"领班真干、导览只指路",今天我们就把这两个工头的源码翻个底朝天。
L01

什么是 Supervisor 模式

🤔 先想一个问题假设你手上有 4 个专家 Agent(trace/metric/deploy/log),来了一句"支付超时怎么回事"。让 4 个专家互相 @、自己商量谁先上行不行?行,但你很快会发现:没人知道全局进度、两个专家可能同时得出矛盾结论、出了错找不到"是谁决定这么干的"。这就是"去中心化"的代价。

Supervisor(主管)模式就是对上面问题的经典答案:设一个"中央主管",它负责接任务、判断该派给哪个"下属"去做、收集结果,下属之间不直接对话,全部经过主管

Supervisor 主管接任务 · 决定派给谁 · 汇总
↙   ↓   ↓   ↘
worker A
worker B
worker C
worker D
💡 本质主管模式把"调度逻辑"集中到一个点——你永远知道现在是谁在干、为什么派给它、走了几步。代价是主管可能成为瓶颈。本框架选主管模式,因为它的第一优先级是可信 / 可控 / 可观测,而不是极致并发。
L02

为什么不用官方的 langgraph-supervisor 库

LangChain 官方出过一个 langgraph-supervisor 库。但本框架默认不用它,选择完全自实现。理由直接写在源码的模块 docstring 里——dispatch.py:11-13

"""supervisor_node · 自实 LLM dispatch · zero deps on `langgraph-supervisor` lib
...
revise 2026-05-22:
- LLM default 走 toolkit get_llm("haiku") · 不强 import langchain_anthropic
- 完全自实 · 不依赖 langgraph-supervisor lib(v0.1 不稳 + venv 未装)
"""

逐句读:"v0.1 不稳"——那个库当时还是 0.1 版本,行为不稳定;"venv 未装"——环境里根本没装它,硬依赖会让整个包 import 就炸。所以框架的选择是:用最原始的 LangGraph StateGraph 自己拼一个 supervisor,只有 100 多行,行为完全可控。

这给你一个重要认知:AI/Agent 领域演进极快,"官方推荐"半年就翻篇。本框架的应对是 Day 01 讲过的分层——把易变的东西(supervisor 实现方式)放在能替换的位置,稳定的可信能力(toolkit)攥在自己手里。于是问题变成:框架自己怎么实现?答案是——它有两种,分别解决两个不同层面的问题。这是今天最容易搞混、也最该厘清的地方。
L03

两种 supervisor 全景(务必分清)

① 图内 supervisor_node(进程内)

包:ai-trust-toolkit-starter-supervisor

  • 解决:"一个 agent 内部,下一步该调哪个 specialist(专家)"
  • 是 LangGraph 图里的一个节点
  • 真的会去执行被选中的专家(跑完再回到它)
  • 用在:单个复杂 agent 的图内调度

② 跨 agent router(进程外)

app:apps/supervisor-router-agent

  • 解决:"一句话任务,该路由到平台上哪个 agent"
  • 是一个独立的 agent/服务没有 LangGraph 图
  • 只出决策、不真执行(scope guard)
  • 用在:工程师说人话,帮他选对入口
一句话区分:①是"图内、选专家、真跑";②是"进程外、选 agent、只给建议不跑"。L04-L06 拆①的源码,L07-L08 拆②的源码。

👶 小白:都叫 supervisor,我还是分不清什么时候是哪个……

👨‍🏫 老师:用工地类比。① 施工领班站在一户装修工地内部,手里是这户的专家名单(trace/metric/deploy/log 师傅),他喊"下一个上 trace 师傅",师傅真的上去干——所以它是图里的一个节点,会执行。② 大厅导览员面对整个小区几十户,你问"我这需求找谁",他答"去 risk-reviewer 那户",但他不会拉着你去敲门。看它在"一户内"还是"跨户"、"真干"还是"只指路",就分清了。

L04

① 图内 supervisor 的黑板:SupervisorState 三字段

要读懂图内 supervisor,先看它读写的"黑板"(回忆 Day 03 的 State)。它定义在 state.py:24,只有三个字段

class SupervisorState(TypedDict, total=False):          # state.py:24
    # LangGraph messages reducer · 累积对话
    messages: Annotated[list[Any], add_messages]        # state.py:34
    # supervisor 调度历史 · ["rca", "trace", "rca"] 形式
    dispatch_history: list[str]                          # state.py:37
    # 终止时填 · None 表示仍在 dispatch loop
    final_answer: str | None                             # state.py:39
messages
对话历史。用 LangGraph 自带的 add_messages 作 Reducer(Day 03 学过),新消息自动追加而非覆盖。
dispatch_history
主管走过哪些专家,形如 ["rca","trace","rca"]。它是防死循环给 Critic 排查路由的依据。
final_answer
None = 还在调度循环里;一旦被填上字符串,就代表主管决定收尾,图要走向 END。

total=False 表示这三个字段都是可选的(黑板一开始可以是空的,节点逐步往上写)。source 里还有一段很务实的兜底state.py:15-21):如果环境没装 langgraph,就退化成一个只做 append 的 add_messages 假实现,保证这个 schema"光 import 也不炸"。

SupervisorState:一块被主管和专家轮流写的黑板 messages [Human "支付超时?", AI "已查 trace…", …] ← add_messages 追加 dispatch_history ["rca", "trace", "rca"] ← 走过谁 · 防死循环 / 给 Critic 看 final_answer None = 循环未结束 · "根因是数据库连接池打满" = 收尾 → END
图注:三字段各司其职——messages 是内容、dispatch_history 是轨迹、final_answer 是终止信号。
💡 设计取舍①:为什么钦定这个统一 state?starter 包的 PKG 说明里写得很直白:"钦定 state-based(messages + dispatch_history + final_answer)· 减少 12 agent 之间 pattern 漂移"。如果每个 agent 自己发明状态字段名,12 个 agent 就有 12 种写法,读代码、调路由、写 Critic 全得重新适应。统一 schema 是用一点"灵活性"换"全平台一致可读"。
L05

① 图内分派核心:supervisor_node 逐段走读

代码在 packages/ai-trust-toolkit-starter-supervisor/.../dispatch.py。它的活就一件:看当前黑板,让便宜 LLM 决定"下一个调谁"或"给最终答案"。我们分四段读。

① 主管给 LLM 的"任务书"(dispatch.py:29)

def _build_supervisor_system_prompt(specialist_names: list[str]) -> str:
    names_str = ", ".join(f'"{n}"' for n in specialist_names)
    return (
        "你是一个 supervisor 节点 · ... 输出 JSON。可选 specialist:[" + names_str + "]。\n"
        'JSON 输出格式(只输出 JSON · 不附其他文本):\n'
        '  - 继续调度 specialist:{"next": "<specialist_name>"}\n'
        '  - 终止 + 给最终答案:{"final_answer": "..."}\n'
        "调度策略:\n"
        "- 若 dispatch_history 已含同一 specialist 多次且无新信息 · 终止 final_answer")

大白话:主管严格约束 LLM 只能输出两种 JSON 之一——要么 {"next":"trace"}(继续,调这个专家),要么 {"final_answer":"..."}(够了,收尾)。它还把"同一个专家反复调又没新信息就该收尾"这条软规则写进了提示词,从源头减少打转。

② 防死循环的硬闸门(dispatch.py:100-115)

dispatch_history = state.get("dispatch_history", [])
# 防无限循环 · max_iter 兜底
if len(dispatch_history) >= max_iter:                 # dispatch.py:103
    log.warning("supervisor max_iter=%d reached · 强制 final_answer", max_iter)
    return {
        "final_answer": f"达到最大调度次数(max_iter={max_iter})· 强制终止 · ...",
        "next": END_MARKER,                            # dispatch.py:26 END_MARKER="__END__"
    }

这是今天第一个关键的边界处理:上面提示词那条软规则是"劝"LLM 别打转,但 LLM 不一定听话。所以这里有一道硬闸门:调度历史长度一旦到 max_iter(默认 10),不管 LLM 想怎样,直接强制填 final_answer 收尾。"劝 + 硬拦"双保险,是所有 LLM 循环控制的标准姿势。

③ 便宜 LLM 出决策,异步优先(dispatch.py:117-146)

if llm is None:
    from ai_trust_toolkit.llm import get_llm
    actual_llm = get_llm("haiku")            # 默认用便宜的 Haiku 做调度 · dispatch.py:121
specialist_names = sorted(specialists.keys())      # 排序 → 提示词稳定 · 可缓存
...
ainvoke = getattr(actual_llm, "ainvoke", None)
if ainvoke is not None and callable(ainvoke):
    response = await ainvoke(full_messages)   # 有异步就走异步 · dispatch.py:144
else:
    response = actual_llm.invoke(full_messages)

两个细节:(1)默认 get_llm("haiku")——调度只是"选下一步",不需要贵模型,用便宜的 Haiku 省钱(这是全框架的成本纪律,Day 11 细讲)。(2)sorted(specialists.keys()) 把专家名排序,保证同一批专家生成的提示词字节完全一致——这对prompt 缓存命中很重要。

④ 解析结果 + 三种出路(dispatch.py:158-183)

parsed = _parse_dispatch_response(content)
# 优先看 final_answer
if "final_answer" in parsed and parsed["final_answer"]:
    return {"final_answer": str(parsed["final_answer"]), "next": END_MARKER}
next_name = parsed.get("next")
if next_name in specialists:
    return {"next": next_name}                        # 正常:派给这个专家
# LLM 给了 next 但不在名单 · 视为终止(不硬塞一个不存在的专家)
log.warning("supervisor LLM 返 next=%r 不在 specialists ...", next_name)
return {"final_answer": f"supervisor 选择无效 specialist={next_name!r} ...", "next": END_MARKER}

三条出路:有 final_answer → 收尾;next 在名单里 → 派专家;LLM 幻觉出一个不存在的专家名 → 不冒险执行,直接当收尾处理(第 3 条又是一处边界防御)。

💡 设计取舍②:解析为什么要"三级容错,且最后兜底也返回 final_answer"?_parse_dispatch_responsedispatch.py:45):它先试直接 json.loads,失败再试剥掉 markdown ```json``` 包裹(:59),再失败就正则抓第一个 {...}:69)。连这些都失败呢?它不抛异常,而是返回 {"final_answer": "supervisor 输出非 JSON · 原文:…"}:79)。为什么?因为这是循环里的节点——若解析失败就抛异常,整个图崩;若返回 next 又可能无限打转。兜底成"终止"是最安全的选择:宁可给个不完美的收尾,也不让图崩或死循环。

⚠️ 易错点:很多人以为"LLM 输出 JSON 就直接 json.loads 一下"。真实 LLM 常在 JSON 外面裹一层解释文字或 markdown 代码块,直接 loads 必挂。生产级解析永远是"多级容错 + 失败兜底",dispatch 和后面的 router 都是这个套路。
L06

① 把节点接成图:auto_configure_supervisor

光有 supervisor_node 还不是一张图。auto_config.py:37auto_configure_supervisor() 一行把整张图连好:

def auto_configure_supervisor(app, *, specialists, llm=None,
                              enable_critic=True, max_iter=10):
    if not enable_critic:                              # auto_config.py:65 红线
        raise ValueError("R-STARTER-SUPERVISOR-2 红线 · critic 不可关 ...")
    if not specialists:
        raise ValueError("specialists dict 不能为空 · 至少 1 个 specialist")

    builder = StateGraph(SupervisorState)
    builder.add_node("supervisor", _supervisor)        # 主管节点
    for name, runnable in specialists.items():
        builder.add_node(name, runnable)
        builder.add_edge(name, "supervisor")           # 专家跑完 → 回主管
    builder.add_edge(START, "supervisor")              # 入口
    # 主管出口是条件边:路由到某专家,或到 END
    conditional_map = {name: name for name in specialists}
    conditional_map[END_MARKER] = END                  # __END__ → LangGraph END
    builder.add_conditional_edges("supervisor", route_after_supervisor, conditional_map)
    return builder.compile()

逐行翻译:建一张以 SupervisorState 为黑板的图 → 加主管节点 → 每个专家加成节点,并且专家跑完统一回到主管add_edge(name,"supervisor"),这就是"下属不互相对话、全走主管"的代码化身)→ 入口连到主管 → 主管的出口是一条条件边route_after_supervisor 返回的字符串(专家名或 __END__)在 conditional_map 里查到下一站。

那个条件路由函数极短(dispatch.py:186):

def route_after_supervisor(state) -> str:
    next_name = state.get("next")
    if next_name is None or next_name == END_MARKER:
        return END_MARKER            # 走向 END
    return str(next_name)            # 走向某个专家
图内 supervisor 是一台"回到自己"的状态机 START supervisor Haiku 出 next / final specialist trace specialist metric specialist … 专家跑完 → 回主管 END final_answer → END
图注:START→supervisor→(条件边)→某专家→回 supervisor→…→final_answer→END。专家永远不直连专家。
💡 设计取舍③:为什么 enable_critic=False 要直接 raise,而不是"默认关掉"?auto_config.py:65——只要有人想关掉 Critic,这个函数立刻抛 ValueError 拒绝组装,注释标着"红线 · critic 不可关"。这是"把安全约束写死在代码里、让它无法被绕过"的思路:可信 Agent 的防幻觉不是"可选项",而是组装时的硬前置。同样地,specialists 为空也直接 raise——一个连专家都没有的主管毫无意义,早失败好过运行时诡异行为。
L07

② 跨 agent 路由:RouteDecision 与 route() 五步

换到第二个"工头"——大厅导览员。代码在 apps/supervisor-router-agent/supervisor_router/core.py。它没有 LangGraph 图,就是一个函数 route(task, tier)。先看它的产物数据结构 RouteDecisioncore.py:37):

@dataclass(frozen=True)                     # core.py:37 · 冻结 = 不可变
class RouteDecision:
    agent_id: str | None                    # 选中的 agent · None = 不确定
    confidence: float                        # 置信度 0~1
    args_hint: dict[str, str]                # 建议参数
    reasoning: str                           # 为什么这么选
    suggested_command: str                   # 建议的 shell 命令
    fallback_alternatives: list[str]         # 备选 agent
    token_usage: dict | None = None          # 花了多少 token / 钱(可观测)
    tier_used: str = DEFAULT_TIER
    latency_ms: int = 0
💡 设计取舍④:为什么用 @dataclass(frozen=True)frozen 让这个决策一旦生成就不可改。路由决策是要被记录、审计、回放的东西——如果它能被下游随手改字段,"当时到底建议了什么"就成了糊涂账。冻结 = 决策留痕不可篡改。这和 Day 04 后面的 HandoffSignal 用同样的 frozen 取舍。

再看主函数 route()core.py:53)的五步骨架(已裁剪 try/except 细节):

def route(task, *, tier=None) -> RouteDecision:
    if not task or not task.strip():                    # core.py:59 空任务早返回
        return RouteDecision(agent_id=None, confidence=0.0, reasoning="task 空 · 给个具体任务", ...)
    registry = list_agents()                             # ① 拿 agent 名单 · core.py:74
    if not registry:
        return _unsure("registry 空 ...", ...)
    try:
        from ai_trust_toolkit.llm import get_llm
        llm = get_llm(tier=actual_tier)                  # ② 便宜模型 · core.py:86
    except Exception as e:
        return _unsure(f"LLM 不可用 ... {e}", ...)        #    AC-1.5 挂了不抛
    system, user = _render_prompts(task, registry)       # ③ 渲染 prompt · core.py:93
    response = llm.invoke([SystemMessage(...), HumanMessage(...)])  # 调 LLM
    parsed = _extract_json(raw)                          # ④ 正则抓 JSON · core.py:115
    if parsed is None:
        return _unsure("LLM 输出非 JSON · skip", ...)
    decision = _build_decision(parsed, registry, ...)    # ⑤ 组装 RouteDecision
    decision = _critic_validate(decision, registry)      #    再 critic 校验
    return decision
1

list_agents()

平台上有哪些 agent(优先查门户 registry,失败用本地 mock)。空 → 直接 unsure。

2

get_llm(haiku)

便宜模型做决策。初始化失败(没 key / import 错)→ 优雅 unsure,不抛。

3

渲染 prompt → invoke

把任务 + agent 名单塞进 Jinja2 模板让 LLM 选。网络/限流错 → 优雅 unsure。

4

正则抓 JSON

_extract_json 抓第一个 {...}core.py:175)。抓不到 → unsure。

5

_build_decision + _critic_validate

组装 RouteDecision,再过一道 critic 校验(下一讲)。

⚠️ 注意每一步都包在 try/except 里,任何异常都转成"优雅 unsure"而不是抛出去——这是 L08 要讲的核心设计,也是路由器和图内主管最大的行为差异。
L08

② critic 校验 + 优雅降级 + scope guard

route() 拿到 LLM 给的 RouteDecision不直接信,要过一道 _critic_validatecore.py:278)。这是路由器的"防幻觉":

def _critic_validate(decision, registry) -> RouteDecision:
    if decision.agent_id is None:
        return decision                                  # 已经是 unsure · 直接返
    valid_ids = {a.agent_id for a in registry}
    if decision.agent_id not in valid_ids:               # ① LLM 编了个不存在的 agent
        log.warning("LLM 选 %r 不在 registry · fallback unsure", decision.agent_id)
        return RouteDecision(agent_id=None, confidence=0.0,
            reasoning=f"LLM 选 {decision.agent_id} 不在 registry · fallback unsure", ...)
    min_conf = float(os.getenv("PORTAL_SUPERVISOR_MIN_CONFIDENCE", "0.5"))
    if decision.confidence < min_conf:                   # ② 置信度太低
        return RouteDecision(agent_id=None, confidence=decision.confidence,
            reasoning=f"confidence {decision.confidence:.2f} < {min_conf} · 不自信 · 工程师手挑",
            fallback_alternatives=[decision.agent_id] + decision.fallback_alternatives, ...)
    return decision                                      # 通过

两道校验:① LLM幻觉出一个不在名单里的 agent → 退回 unsure;② 置信度低于阈值(默认 0.5) → 也退回 unsure。注意第②处的细节:低置信时它把 LLM 原本想选的 agent 放进 fallback_alternatives第一位,而不是丢掉——这样工程师看到"不确定,但我猜是 risk-reviewer",可以手动挑回。

💡 设计取舍⑤:低置信为什么"降级为不确定但保留猜测"而非"直接给答案"?路由器宁可说"我不确定,候选可能是它",也不硬塞一个自己都没把握的窗口——因为塞错窗口会让工程师白跑一趟、甚至误触发下游。但完全丢掉 LLM 的猜测又太浪费。把猜测降级进 fallback,是"诚实 + 不浪费信息"的平衡。

再看那个反复出现的 _unsurecore.py:130)——它是所有失败路径的统一出口:

def _unsure(reason, tier, start, latency=None) -> RouteDecision:
    return RouteDecision(agent_id=None, confidence=0.0, args_hint={},
        reasoning=reason, suggested_command="", fallback_alternatives=[],
        tier_used=tier, latency_ms=latency if latency is not None else ...)
💡 设计取舍⑥(关键):为什么 route() 任何一步失败都返回 unsure,而不抛异常?源码在 route() 的 docstring 里写死了:"任何阶段挂 SHALL 返 unsure 不抛"。为什么?因为路由器是被别人调用的服务——CLI、门户、其他 agent 都靠它。如果它遇到"没 API key""网络抖动""LLM 抽风"就抛异常,会把调用方一起拖崩。让它"永远返回一个合法的 RouteDecision(哪怕是 unsure)",调用方的代码就永远好写:拿到决策看一眼 agent_id 是不是 None 即可,不用到处包 try。这就是"优雅降级 > 抛异常"在服务边界上的体现。
📝 举个例子:导览员怎么应答两种问法 输入 route("帮我看下这个 PR 的上线风险") → 高置信 → RouteDecision{agent_id:"risk-reviewer", confidence:0.86, suggested_command:"agentctl invoke risk-reviewer ...", fallback_alternatives:["doc-checker"]}

输入 route("今天天气怎么样")(平台没这类 agent)→ LLM 可能瞎选一个 → _critic_validate 发现不在名单 / 置信度 < 0.5 → 返回 RouteDecision{agent_id:None, confidence:0.0, fallback_alternatives:["原来那个瞎选的"]}它宁可说"不确定",也不硬塞一个错窗口。

最后一块拼图是 scope guard(范围守卫)core.py:1-15 的模块 docstring 明确写"不真执行下游 agent(scope guard)"。route() 只吐"该找谁 + 命令怎么写",绝不替你真的去调

🚫 如果 router 自动执行下游

  • 一个决策错误会级联触发一串真实操作(回滚、重启)
  • 权限/审计边界模糊,谁为副作用负责?
  • 成本不可控,一句话可能烧一大笔

✅ 只出决策的好处

  • 人(或上层)看过决策再决定要不要执行
  • 决策可解释、可审计、可回放(配合 frozen)
  • 路由器本身零副作用、极安全

这个思想在框架里到处都是——Day 15 会讲的 spec-executor 只往 agent/* 分支提交、绝不碰 main。把"决策"和"执行"分开,是可信 Agent 系统的一条通用安全原则。

L09

handoff 与 route_chain:多步接力

有时一个任务要好几个 agent 接力完成。框架用两个东西支持,且依然遵守 scope guard——只建议、不自动执行

HandoffSignal(交接信号 · handoff.py:14)

@dataclass(frozen=True)                   # handoff.py:14
class HandoffSignal:
    next_agent_hint: str | None           # canonical agent_id · None = 链终止
    context_for_next: dict | None         # 传给下一棒的上下文
    chain_id: str | None                  # 整条链共享的 ULID
    reason: str                           # 给 supervisor 看的理由

逐字段:一个 agent 干完,可以在返回结果上挂这个信号——next_agent_hint 是"建议下一个谁接手"(None 表示这条链到此结束);context_for_next 是传给下一棒的数据;reason 是理由,比如"verify fail · 需 prod pod 兜底"。但这同样只是建议,由 supervisor 决定要不要真交接(又是 scope guard)。

chain_id 值得单独说:它由 new_chain_id()handoff.py:31)生成,而这个函数直接复用了 envelope 的 new_case_id()(同一套 ULID 生成器)。为什么?docstring 说得清楚:让 chain_id 和门户里 invocation 的 case_id 格式一致,串库时无需额外校验。一个 26 字符、按时间可排序的 ID,把一条接力链的多次调用串起来。

route_chain(多步规划 · core.py:322)

route_chain(task, max_steps=5) 让 LLM 一次性规划出 2-5 步的 agent 调用链。它的循环体(core.py:392-402)藏着两处边界防御:

decisions = []
for step in steps_raw[:max_steps]:            # AC-3.4:超出 max_steps 直接截断
    if not isinstance(step, dict):
        break                                 # AC-3.3:半途冒出非法项 → 截断,保留前几步
    d = _build_decision_from_step(step, registry, ...)
    d = _critic_validate(d, registry)         # 每一步都过 critic
    if d.agent_id is None:
        break                                 # AC-3.3:某步选了名单外 agent → 截断
    decisions.append(d)
return decisions

大白话:规划不是"要么全成功要么全失败"。若第 3 步 LLM 选了个不存在的 agent,它不会丢掉前 2 步,而是"截断"——保留已经合法的前缀,把断掉的地方给你看。这比"全砍掉重来"友好得多。另外 max_steps==1 时(core.py:341)它直接退化成单步 route(),保持向后兼容。

任务 1. requirement-discoverer 2. spec-author 3. spec-executor 4. release-coordinator

依然是只规划、不执行——打印出计划 + 共享 chain_id 给你看。上面这条链其实就是 Day 15/20 要讲的平台"自我开发"闭环。

👶 小白:图内主管的 max_iter 和 route_chain 的 max_steps 是一回事吗?

👨‍🏫 老师:像但不同。max_iter(图内)管的是"运行时真跑了多少步",防止专家被反复调进死循环;max_steps(router)管的是"一次性规划出几步计划",且这计划不真跑只打印。前者是执行护栏,后者是规划上限——正好对应两个工头"真干 vs 只指路"的本质差异。

L10

今日小结 + 动手

🧠 今天你应该能回答

  • Supervisor 模式的核心结构?(中央主管调度,下属不直接对话、全走主管)
  • 为什么不用 langgraph-supervisor 库?(v0.1 不稳 + venv 未装,框架自实现只 100 多行、完全可控)
  • SupervisorState 哪三字段、各管什么?(messages 内容 / dispatch_history 轨迹 / final_answer 终止信号)
  • supervisor_node 怎么防死循环?(提示词软劝 + max_iter 硬拦 + 解析失败兜底成 final_answer)
  • route() 为什么任何失败都返回 unsure 不抛异常?(服务边界优雅降级,不拖垮调用方)
  • _critic_validate 怎么防路由幻觉?(不在名单 / 低置信 → 退回 unsure,低置信时把猜测留进 fallback)
  • scope guard 是什么?为什么重要?(只决策不执行,可解释可审计可回放)

✋ 动手:跟着真源码读一遍

# ① 图内 supervisor 的三段核心:任务书 / max_iter / 三种出路
sed -n '29,42p;100,115p;158,194p' packages/ai-trust-toolkit-starter-supervisor/src/*/dispatch.py

# ② 三字段黑板 + 一行组装成图(含 enable_critic 红线)
sed -n '24,41p' packages/ai-trust-toolkit-starter-supervisor/src/*/state.py
sed -n '37,124p' packages/ai-trust-toolkit-starter-supervisor/src/*/auto_config.py

# ③ 跨 agent router 的 RouteDecision / route() 五步 / critic 校验 / 优雅降级
sed -n '37,146p;278,319p' apps/supervisor-router-agent/supervisor_router/core.py

# ④ handoff 数据结构 + route_chain 的截断逻辑
sed -n '14,39p' packages/ai-trust-toolkit/src/ai_trust_toolkit/handoff.py
sed -n '322,402p' apps/supervisor-router-agent/supervisor_router/core.py

# ⑤(若已启用)用 CLI 试试路由决策
uv run supervisor-router "帮我看下这个 PR 的上线风险" --chain
明天预告 · Day 05:第 1 周收官。今天读的图内 supervisor 是"多专家怎么被调度",明天把镜头拉到"一个真实 Agent 的标准目录长什么样"——state / builder / nodes / specialists / prompts / tools / server 各是什么、怎么配合。这是 Day 14 逐文件精读 sre-rca 的地图。
← Day 03 LangGraph 入门 Day 05 · 一个 Agent 的标准解剖 →