编排系列收官篇。前两篇分别讲了多 Agent 编排的四种核心模式(串行/并行/条件/竞争)和 Agent 间的通信、状态共享与冲突解决。本篇从概念落地到真实代码,以 Dream-SaaS 项目为例,拆解 Supervisor + Orchestrator 双层编排架构的设计思路与关键实现。
一、开篇:为什么需要两层编排?
单 Agent 应用通常不需要编排------一个 ChatClient 调一次 LLM,拿到结果返回,完事。但当系统里出现多个 Agent、多种工具、多种任务类型时,"谁先执行、走哪条路、结果怎么汇总"就成了必须回答的问题。
很多团队的第一反应是用一个 StateGraph 把所有逻辑塞进去:路由、工具调用、安全检查、记忆管理、Provider 选择......节点越加越多,边越连越密,最终变成一个没人敢改的"上帝图"。
Dream-SaaS 在实践中选择了双层编排:
- 外层 Orchestrator(AgentOrchestratorService):负责请求级编排------会话管理、PreLlm 规则链拦截、状态准备(history/retrieval/provider)、调用 Graph、结果提取、记忆维护。管的是"一次对话的生命周期"。
- 内层 StateGraph(dreamAgentGraph):负责推理级编排------route 路由 → tools 工具执行/plan 计划生成 → synthesize 汇总回答。管的是"一次推理内部的节点流转"。
两层的边界很清晰:
Client
│
▼
AgentOrchestratorService(外层:请求级编排)
├── 1. 会话ID & Provider 选择
├── 2. PreLlmRuleChain 规则链拦截(第一层路由)
├── 3. 准备状态:history / retrieval / provider
├── 4. 调用 dreamAgentGraph ──────────────────────┐
├── 5. 提取 final answer & tool outputs │
└── 6. 维护记忆 │
▼
dreamAgentGraph(内层:推理级编排)
START
│
route ──┬── WITH_TOOLS ──→ tools ──┐
│ ├──→ synthesize → END
└── PLAN_THEN_ANSWER → plan ┘
外层管"请求生命周期",内层管"推理流转"。外层不关心 Graph 内部有几个节点、怎么走的条件边;内层不关心会话记忆怎么存、Provider 怎么选。混淆两层的后果就是 Graph 膨胀成上帝对象------安全检查、记忆管理、Provider 路由全做成 Graph 节点,图的拓扑复杂度指数级增长。
这个划分不是拍脑袋决定的。外层的 PreLlm 规则链需要在 Graph 启动前拦截请求(安全合规场景不能让请求进入推理流程),记忆维护需要在 Graph 执行完后统一写入(避免节点内部并发写记忆),这些天然属于"请求级"关注点。而工具路由、计划生成、答案合成是"推理级"关注点,它们需要共享 Graph 的 OverAllState、走条件边、可能循环------这些是 StateGraph 的强项。
二、Supervisor 层:三层路由的确定性梯度
路由是编排的核心决策------"这个请求该走哪条路"。Dream-SaaS 没有把所有路由决策都交给 LLM,而是设计了三层路由,从快到慢、从确定性到不确定性依次执行。
2.1 第一层:PreLlmRuleChain 规则链------零延迟零成本的安全门
规则链在 Graph 调用之前执行,完全不调 LLM。它按 @Order 排序遍历所有 PreLlmRule 实现,任一规则判定拦截,立即返回:
// PreLlmRuleChain.java --- 按@Order排序执行全部PreLlmRule
public RuleEvaluationResult evaluate(PreLlmContext context) {
if (!properties.isRulesBeforeLlm()) {
return RuleEvaluationResult.allow();
}
for (PreLlmRule rule : rules) {
RuleEvaluationResult r = rule.evaluate(context);
if (!r.allowed()) {
return r; // 任一规则拦截,立即返回
}
}
return RuleEvaluationResult.allow();
}
在 AgentOrchestratorService.chat() 中,规则链的拦截结果直接返回给客户端,不进入 Graph,不调 LLM:
PreLlmContext preCtx = new PreLlmContext(conversationId, userInput, provider);
RuleEvaluationResult gate = preLlmRuleChain.evaluate(preCtx);
if (!gate.allowed()) {
// 直接返回规则提示,不进入Graph,不调LLM
return new AgentChatResponse(conversationId, provider, gate.userVisibleMessage(),
List.of("strategy:preLlmBlocked:" + gate.reasonCode()));
}
设计哲学就一句话:"能不调 LLM 就不调"。安全拦截、合规检查、高频简单意图------这些 100% 确定的场景用规则处理,零延迟、零 Token 成本、零幻觉风险。如果一个请求包含违禁词,规则链在毫秒级拦截并返回提示,而不是花 3 秒钟让 LLM 生成一段"我无法回答这个问题"。
2.2 第二层:关键词启发式路由------needsTools 的 80% 场景
通过了规则链的请求进入 Graph。Graph 的第一个节点是 route,它决定请求是走"工具执行"路径还是"计划+回答"路径。这个决策同样不调 LLM,而是用纯关键词匹配:
// AgentToolRunner.java --- 纯关键词匹配
@Override
public boolean needsTools(String message) {
String lower = message.toLowerCase(Locale.ROOT);
return lower.contains("时间") || lower.contains("date") || lower.contains("time")
|| lower.contains("天气")
|| lower.contains("工单") || lower.contains("ticket");
}
在 Graph 的 route 节点中使用:
graph.addNode("route", AsyncNodeAction.node_async(state -> {
String userInput = state.value(AgentGraphKeys.USER_INPUT, "");
String route = toolRunner.needsTools(userInput)
? AgentGraphKeys.ROUTE_WITH_TOOLS
: AgentGraphKeys.ROUTE_PLAN_THEN_ANSWER;
return Map.of(AgentGraphKeys.ROUTE, route);
}));
"查天气""现在几点""工单 TK-1024 什么状态"------这些意图明确包含工具触发词,关键词匹配的准确率足够高。不调 LLM 意味着省掉一次 2-5 秒的推理延迟和几千 Token 的成本。
当然,关键词匹配有盲区。"帮我看看外面要不要带伞"这种隐含天气查询的请求,needsTools 会返回 false,走 plan → synthesize 路径,最终由 LLM 在 synthesize 环节理解意图。这不是 bug------关键词路由的目标是覆盖 80% 的高确定性场景,剩下 20% 交给第三层。
2.3 第三层:LLM 语义路由------留给模糊场景
前两层处理完,剩下的请求进入 synthesize 节点,由 LLM 理解用户意图并生成回答。这是三层中最慢、最贵但也最灵活的一层。
值得注意的是,Dream-SaaS 没有把"路由判断"本身做成一个 LLM 调用节点。很多编排框架喜欢加一个 router 节点,让 LLM 输出 {"route": "tools"} 这样的结构化 JSON 来决定走向。这种做法的问题在于:路由判断本身需要一次 LLM 调用,增加了延迟和成本;而且 LLM 的路由输出不稳定,可能返回格式错误的 JSON,需要额外的解析和重试逻辑。
Dream-SaaS 的做法是:确定性的路由决策全部前置,LLM 只在最终回答环节做语义理解。route 节点用关键词,plan 节点用纯逻辑,只有 synthesize 节点真正调用 LLM。这样一次对话最多只调一次 LLM(工具路径下 tools 节点可能调外部 API 但不调 LLM),延迟和成本都可控。
2.4 UserHelpIntentRouter:纯函数路由器的另一种实现
除了通用对话 Graph 的 needsTools,Dream-SaaS 还有一个 UserHelpIntentRouter,基于配置化的关键词短语匹配,将用户问题路由到四个子图:权限查询、账号问题、操作指南、通用聊天。
它和 needsTools 的思路一致,但粒度更细------不是"要不要调工具"的二元判断,而是"属于哪个领域"的多元分类。配置化意味着新增意图类别不需要改代码,只需要在配置文件中添加关键词短语。
2.5 三层路由的本质是"确定性梯度"
三层路由的本质是"确定性梯度"------越确定的决策越靠前,越不确定的越靠后。规则链处理 100% 确定的安全/合规场景,关键词处理 80% 确定的工具触发场景,LLM 处理剩余需要语义理解的模糊场景。这不是过度设计,而是成本和延迟的必然选择。
很多人看到"三层路由"的第一反应是"有必要这么复杂吗"。算一笔账:假设一次 LLM 调用平均 3 秒、消耗 2000 Token。如果规则链每天拦截 10% 的请求(安全合规),关键词路由每天覆盖 60% 的请求(工具触发),只有 30% 的请求需要 LLM 做语义理解------那么规则链和关键词每天省下的 LLM 调用量是 70%。这不是"过度设计",是真金白银的成本节约和用户体验提升。
反过来看那些"所有路由都交给 LLM"的框架,每次对话都要先让 LLM 判断意图,再执行------延迟翻倍、成本翻倍,而且路由结果还可能不稳定。
三、Orchestrator 层:StateGraph DAG 编排
通过了规则链的请求进入内层 Graph。Dream-SaaS 的 dreamAgentGraph 拓扑非常精简:
START → route → [条件分支]
├─ WITH_TOOLS → tools → synthesize → END
└─ PLAN_THEN_ANSWER → plan → synthesize → END
3.1 Graph 拓扑:条件边驱动的 DAG
条件边是 Graph 的路由核心。addConditionalEdges 根据 route 节点写入的 ROUTE 状态值,决定下一个节点:
graph.addConditionalEdges(
"route",
AsyncEdgeAction.edge_async(
state -> state.value(AgentGraphKeys.ROUTE, AgentGraphKeys.ROUTE_PLAN_THEN_ANSWER)),
Map.of(
AgentGraphKeys.ROUTE_WITH_TOOLS, "tools",
AgentGraphKeys.ROUTE_PLAN_THEN_ANSWER, "plan"));
graph.addEdge("tools", "synthesize");
graph.addEdge("plan", "synthesize");
graph.addEdge("synthesize", END);
两条分支最终汇聚到 synthesize 节点。这是一个典型的 DAG(有向无环图)------没有循环,每个节点只执行一次。循环场景(如 critique-revise)在领域专用 Graph 中实现,通用对话 Graph 刻意保持无环,降低调试复杂度。
3.2 KeyStrategyFactory:状态传递的契约
StateGraph 的节点之间通过 OverAllState 传递数据。每个 state key 需要注册一个合并策略(KeyStrategy),决定当多个节点写同一个 key 时值如何合并。
Dream-SaaS 的通用对话 Graph 中,所有 key 都使用 ReplaceStrategy------后写覆盖先写。这看起来简单,但背后有明确的设计意图:
- 通用对话 Graph 的拓扑是 DAG,不存在并行写同一 key 的场景(tools 和 plan 是互斥分支),所以不需要 AppendStrategy 或 MergeStrategy。
- ReplaceStrategy 语义最清晰:每个节点写入的值就是最终值,不存在"合并"带来的歧义。
- 如果未来引入并行节点,再为需要追加的 key(如 toolOutputs)切换策略。
KeyStrategy 本质上是节点间的数据契约------它声明了"这个 key 的值由谁写、怎么合并"。和微服务中的 API 契约一样,应该最小化、显式化、避免隐式约定。
3.3 Plan 节点为什么不调 LLM
plan 节点是 Dream-SaaS 编排设计中最反直觉的部分。它的名字叫"plan",但完全不调用 LLM ,而是通过 AgentGraphPlanBuilder 用纯逻辑生成结构化计划文本:
public static String buildPlanForDirectPath(String userInput, String retrievalSnippets) {
boolean augmented = hasSubstantiveRetrieval(retrievalSnippets);
String kind = augmented ? AgentGraphKeys.TASK_KIND_KNOWLEDGE
: AgentGraphKeys.TASK_KIND_GENERAL;
StringBuilder sb = new StringBuilder();
sb.append("【任务类型】").append(kind).append('\n');
sb.append("1) 先理解用户目标;若问题含糊,可先澄清再作答。\n");
if (augmented) {
sb.append("2) 下方「检索补充」含可参考材料:事实与数字须与之对齐...\n");
} else {
sb.append("2) 当前无实质检索命中:可结合常识与历史对话作答...\n");
}
// ...
return sb.toString();
}
它判断检索结果是否实质命中,决定任务类型是"知识问答"还是"通用对话",然后按模板生成指令文本。这个文本会作为 synthesize 节点的 prompt 上下文,引导 LLM 按正确的方式回答。
Plan 节点不一定要调 LLM。 很多编排框架把"planning"做成一个 LLM 调用节点,让模型输出一个结构化的任务计划。但 Dream-SaaS 的实践表明:当任务类型可以通过规则判断(有没有检索结果、是不是多步问题),用确定性逻辑生成计划模板更可靠------没有幻觉风险、没有额外延迟、没有额外 Token 消耗。LLM 应该用在真正需要语义理解的"synthesize"环节,而不是机械地组装指令。
这个设计还有一个好处:可测试。纯函数的 PlanBuilder 可以写单元测试,输入不同的 userInput 和 retrievalSnippets,断言输出的计划文本包含正确的指令。LLM 驱动的 plan 节点几乎无法做确定性单元测试。
3.4 synthesize 节点:上下文组装的艺术
synthesize 是 Graph 的最终节点,也是唯一调用 LLM 的节点。它组装所有上下文------history、plan、retrieval、toolOutputs、userInput------然后调用 ChatClient 生成回答。
上下文组装的顺序和格式直接影响 LLM 的回答质量。Dream-SaaS 的组装原则是:
- System Prompt 在前:定义角色和行为约束。
- Plan 指令紧随:告诉 LLM 当前任务类型和回答策略。
- 检索补充居中:如果有 RAG 结果,作为事实依据。
- 工具结果其后:如果走了工具路径,附上工具返回数据。
- 历史对话在后:最近 N 轮对话记录。
- 用户输入最后:当前问题。
这个顺序不是随意的------LLM 对 prompt 开头和结尾的内容更敏感( primacy 和 recency 效应)。角色定义和任务类型放在最前面设定基调,用户问题放在最后面确保不被忽略。
工具路径下还有一个特殊处理:如果工具返回空结果,PlanBuilder 会调用 buildPlanAfterTools() 检测工具结果是否为空,如果为空,在 plan 文本中明确写入"工具未返回有效数据,请告知用户暂时无法获取该信息,不要编造数据"。这行指令能显著降低 LLM 基于"工具被调用了"这个信号编造数据的概率。
"假成功"检测不能只靠输出端。 编排系列中篇提到了 AgentOutputFailureDetector 在输出端检测异常内容,但输入端的防护同样重要。空工具结果、降级提示、错误信息如果不做特殊处理就喂给 LLM,模型可能基于不完整信息"脑补"出合理但错误的回答。输入端显式告知 LLM"这个数据不可用",比在输出端检测幻觉要可靠得多。
四、领域专用 Graph 的两种模式
通用对话 Graph 解决日常问答,但 Dream-SaaS 还有两类需要特殊编排逻辑的领域场景。它们各自有独立的 Graph 实现。
4.1 Human-in-the-Loop:卖家入驻审核的 interruptBefore 模式
卖家入驻审核流程需要人工审批------系统自动初审后,在 review 节点暂停,等待运营人员审核,审核通过后继续执行后续步骤(如签约、开通支付)。
Spring AI Alibaba Graph 通过 interruptBefore 支持这种模式。核心机制是三件套:
① RunnableConfig + threadId 状态持久化
RunnableConfig runConfig = RunnableConfig.builder()
.threadId(graphThreadId)
.build();
OverAllState state = sellerEnablementGraph.call(inputs, runConfig)
.orElseThrow(() -> new IllegalStateException("Graph 返回空状态"));
每次 Graph 调用携带一个 threadId,Graph 的状态快照与 threadId 绑定。中断后状态被持久化,不会因为 JVM 重启丢失(前提是配置了持久化存储)。
② stateOf 检测中断点
String nextNode = sellerEnablementGraph.stateOf(runConfig)
.map(StateSnapshot::next)
.orElse("");
boolean interrupted = "review".equalsIgnoreCase(nextNode);
Graph 在 interruptBefore 指定的节点前暂停。通过 stateOf 查询当前状态快照的 next 字段,可以判断流程是否停在了 review 节点。如果 next 是 review,说明流程已暂停等待人工输入。
③ updateState 注入人工决策恢复执行
运营人员做出审核决定后,调用 resumeReview() 方法,通过 updateState 将审核结果注入 Graph 状态,然后恢复执行:
// 伪代码:注入人工决策后恢复
sellerEnablementGraph.updateState(runConfig, Map.of(
"reviewDecision", approved ? "APPROVED" : "REJECTED",
"reviewComment", comment
));
sellerEnablementGraph.call(resumeInputs, runConfig); // 从断点继续
Human-in-the-Loop 不是"加个审批"。 它需要状态持久化(threadId)、中断点检测(stateOf)、恢复机制(updateState)三件套,缺一不可。很多团队以为 HITL 就是"在流程中插一个审批节点",但没有状态持久化,审批人一刷新页面流程就丢了;没有中断点检测,不知道流程是在等待审批还是已经异常退出;没有恢复机制,审批结果无法回注到 Graph 中继续执行。
此外,SellerFlowOrchestratorService 还有一个 paymentMaxRetries 参数(取值范围 1-10),限制支付环节的最大重试次数,防止因支付网关临时故障导致无限重试。
4.2 Critique-Revise 闭环:内容生成的质量门控
内容生成场景(如商品描述、营销文案)需要质量保证。Dream-SaaS 的 EnhancedContentGraphConfig 实现了 Critique-Revise 闭环:
draft → critique → qualityGate
├── PASS → merge → END
└── REVISE → revise → critique(循环)
关键设计:
条件属性开关 :通过 @ConditionalOnProperties 控制 enhanced-enabled,开关关闭时退化为简单的 draft → END 流程,便于灰度发布和回退。
质量门控 :QualityGateNode 读取 critique 的评审结果,判断是否通过。不通过则路由到 revise 节点修订,修订后重新进入 critique 评审。
循环次数控制 :Graph 维护两个计数器------REVISION_COUNT(修订总次数)和 GATE_RETRY_COUNT(门控重试次数),任一达到上限即跳出循环。这是必须的,否则 critique 永远不满意,Graph 会无限循环。
并行分支与合并 :支持 ParallelBranchNode 并行生成多个版本,通过 MergeNode 合并选优。并行节点使用独立的 state key 避免写冲突。
FallbackStrategy 降级:当闭环达到最大迭代次数仍未通过质量门控时,执行降级策略------返回当前最佳版本并标记"质量未达标",而不是直接报错。
ContentGraphMetrics 指标收集:记录每次闭环的迭代次数、通过率、降级率,用于监控内容质量和评估 prompt 效果。
4.3 选型:什么时候用哪种模式?
| 维度 | 通用对话 Graph | HITL Graph | Critique-Revise Graph |
|---|---|---|---|
| 拓扑 | DAG(无环) | DAG + 中断点 | 有环(循环) |
| 人工介入 | 不需要 | 必须(审核节点) | 不需要 |
| LLM 调用次数 | 1 次 | 0-多次 | 多次(critique+revise) |
| 延迟要求 | 秒级 | 分钟/小时级 | 十秒级 |
| 质量保障 | LLM 自检 | 人工审核 | 自动评审+门控 |
| 典型场景 | 日常问答、工具调用 | 入驻审核、合规审批 | 文案生成、报告撰写 |
选型原则很简单:需要人做决策的用 HITL,需要机器反复打磨质量的用 Critique-Revise,一次性问答用通用 Graph。不要在通用对话 Graph 里加循环------那会让简单场景的延迟和成本不可控。
五、状态管理:请求生命周期
外层 Orchestrator 的 chat() 方法是整个请求生命周期的编排入口。完整的 chat 流程:
public AgentChatResponse chat(AgentChatRequest request) {
// 1. 准备会话ID和Provider
String conversationId = resolveConversationId(request);
String provider = clientSelector.normalizeProvider(request.provider());
// 2. PreLlm规则链拦截(第一层路由)
PreLlmContext preCtx = new PreLlmContext(conversationId, request.userInput(), provider);
RuleEvaluationResult gate = preLlmRuleChain.evaluate(preCtx);
if (!gate.allowed()) {
return new AgentChatResponse(conversationId, provider,
gate.userVisibleMessage(),
List.of("strategy:preLlmBlocked:" + gate.reasonCode()));
}
// 3. 准备Graph输入状态
String history = renderHistory(conversationId);
Map<String, Object> inputs = new HashMap<>();
inputs.put(AgentGraphKeys.CONVERSATION_ID, conversationId);
inputs.put(AgentGraphKeys.USER_INPUT, request.userInput());
inputs.put(AgentGraphKeys.PROVIDER, provider);
inputs.put(AgentGraphKeys.HISTORY_TEXT, history);
inputs.put(AgentGraphKeys.RETRIEVAL_SNIPPETS,
retrievalSnippetProvider.retrieve(preCtx).orElse(""));
// 4. 执行Graph
Optional<OverAllState> finished = dreamAgentGraph.call(inputs);
OverAllState state = finished.orElseThrow(...);
// 5. 提取结果
String answer = state.value(AgentGraphKeys.FINAL_ANSWER, "");
List<String> executedTools = extractExecutedTools(state);
// 6. 维护记忆
appendMemory(conversationId, "user", request.userInput());
appendMemory(conversationId, "assistant", answer);
return new AgentChatResponse(conversationId, provider, answer, executedTools);
}
记忆管理使用 ConcurrentHashMap<String, Deque<String>>,每个会话 ID 对应一个双端队列,保留最近 12 轮(MAX_TURNS=12),超出自动淘汰最早轮次。
六、可观测性:让黑盒变透明
多 Agent 编排的可观测性比单 Agent 更难。Graph 内部的节点跳转对外部是黑盒------Controller 层只看到调用了 dreamAgentGraph.call(),返回了一个结果,但中间 route 走了哪条路、tools 调了什么 API、synthesize 耗时多久,全部不可见。
6.1 为什么 Trace 必须在编排层捕获
如果只在 Controller 层打日志,看到的是:
10:00:01 POST /api/chat → 200 (3.2s)
仅此而已。3.2 秒花在哪里?route 判定用了多久?tools 节点调用外部 API 耗时多少?synthesize 调 LLM 用了几次?完全不知道。
Trace 必须在编排层捕获,因为只有编排层知道 Graph 内部的节点流转。外层 Orchestrator 在 chat() 入口生成 conversationId 作为 traceId,内层 Graph 通过 OverAllState 的 CONVERSATION_ID 传递这个 traceId,每个节点在入口和出口记录耗时和状态。
6.2 conversationId 作为 traceId 贯穿全链路
// 外层生成traceId
String conversationId = resolveConversationId(request);
// 写入Graph状态,内层节点可读取
inputs.put(AgentGraphKeys.CONVERSATION_ID, conversationId);
// 日志MDC
MDC.put("traceId", conversationId);
这样从 Controller → Orchestrator → Graph 节点 → 工具调用 → LLM 请求,所有日志都可以通过同一个 traceId 串联。排查问题时,用 conversationId 一搜,整条链路的日志按时间线排列,哪个节点慢、哪里报错一目了然。
6.3 ToolTrace:工具级调用记录
各领域 Agent 有独立的 ToolTrace 实现:SellerToolTrace、FulfillmentToolTrace、TicketMgmtToolTrace。它们记录每次工具调用的:
- 工具名称和输入参数
- 调用开始/结束时间和耗时
- 返回状态(成功/失败/超时)
- 错误信息(如有)
这些 Trace 数据与 conversationId 关联,可以回溯"这个回答用了哪些工具、工具返回了什么"。
6.4 @WithAgentTrace AOP + Span 树(E1 模块)
E1 模块(已交付 Prompt)引入了 @WithAgentTrace 注解和 AgentTraceAspect 切面,自动为标注的方法创建 Span,形成调用树:
chat() [3.2s]
├── PreLlmRuleChain.evaluate() [2ms]
├── retrievalSnippetProvider.retrieve() [180ms]
└── dreamAgentGraph.call() [3.0s]
├── route [1ms]
├── tools [850ms]
│ ├── WeatherAPI.call() [420ms]
│ └── TicketAPI.query() [380ms]
└── synthesize [2.1s]
└── ChatClient.call() [2.0s]
Span 树让耗时分析从"猜"变成"看"------synthesize 占了 65% 的时间(主要是 LLM 推理),tools 占了 27%(外部 API),route 和规则链几乎可以忽略。优化方向一目了然。
6.5 跨层深链:Graph 内部节点 → Orchestrator → Controller
可观测性的最终目标是"从一次用户请求能追溯到每一个内部步骤"。Dream-SaaS 的跨层链路是:
- Controller 层:记录 HTTP 请求/响应、conversationId、总耗时。
- Orchestrator 层:记录规则链拦截结果、检索命中情况、Graph 执行结果、记忆写入状态。
- Graph 层:每个节点记录输入 state 摘要、输出 state 摘要、节点耗时。
- 工具层:ToolTrace 记录外部 API 调用详情。
- LLM 层:记录 model、prompt tokens、completion tokens、调用耗时。
所有层的日志通过 conversationId/traceId 串联。当用户反馈"回答不对"时,可以完整复盘:规则链是否放行了?route 走了工具还是 plan?工具返回了什么?LLM 收到了什么 prompt、输出了什么?这种级别的可观测性是多 Agent 系统在生产环境稳定运行的基础。
七、结语:编排架构的设计取舍
三篇编排系列文章,从四种模式到通信与冲突,再到本篇的双层架构拆解,覆盖了多 Agent 编排的核心议题。几个关键结论:
- 双层编排的核心是边界划分------外层管"请求生命周期"(安全/记忆/检索/Provider 选择),内层管"推理流转"(节点路由/工具执行/答案合成)。混淆两层会导致 Graph 膨胀成上帝对象,维护成本指数级增长。
- 路由决策遵循"确定性梯度"------规则链处理 100% 确定的场景,关键词启发式覆盖 80% 的工具触发,LLM 只在需要语义理解的模糊场景介入。越确定越靠前,这是延迟和成本的必然选择,不是过度设计。
- Plan 节点不一定要调 LLM------能用确定性逻辑生成的计划模板就不用 LLM。纯函数 PlanBuilder 可测试、无幻觉、零额外延迟。LLM 是稀缺资源,应该用在真正需要语义理解的 synthesize 环节。
- Human-in-the-Loop 需要三件套------状态持久化(threadId)、中断点检测(stateOf)、恢复机制(updateState),缺一不可。"加个审批节点"不是 HITL,只是一个需要人填的表单。
- 可观测性必须在编排层建设------Graph 内部的节点跳转对 Controller 层是黑盒。conversationId 贯穿全链路 + Span 树 + ToolTrace,让"回答不对"的排查从玄学变成工程。
编排系列到此收官。但 Agent 工程化的挑战远未结束------当系统跑起来后,下一个必须面对的问题是安全:Prompt 注入、工具滥用、数据泄露、越权操作,每一项都可能让精心构建的编排体系在攻击面前不堪一击。
