
Agent Architecture · 工程复盘
多智能体系统真正难的不是“把任务发出去”,而是让调度方和执行方共享同一份事实、同一套产物坐标、同一句人话。本文拆解 SuperAgent 的四层升级:上下文包、结果信封、提示词与资源治理,并给出可落地的分布式与防踩踏方案。
代码基线 ooder-pro / net.ooder.studio.chat.superagent 关键词 GlobalContextPacket · SaResultEnvelope · Router · Worker · 沙箱租约 · 人在环
只有一个 Agent 时,大部分问题都能靠“把提示词写长一点”缓解;当第二个、第三个执行体出现,系统面对的是一组协作问题:谁来决定、谁来执行、结果如何被一致理解、资源如何不打架。这些问题不会因为模型变强而消失,反而会因为参与者变多而放大。
落到工程上,是三个结构性缺陷:
content,不知道上下文、上游产物、下游依赖。典型症状
“正文说已生成,产物横幅却是空的”“同一个失败提示出现两次”“用户追问后系统像没听见”“目标 Agent 已经离线,调用方还在空等”。这些都不是 UI 问题,而是事实没有归一。
把系统剥到最薄,SuperAgent 只做四件事:知道什么(上下文)、决定谁做(决策)、把事做成(执行)、别互相踩(资源)。这四件事分别对应四层能力,也对应本文的四个章节。
层 | 核心问题 | 关键构件 | 失败时的表现 |
|---|---|---|---|
上下文层 | 大家看到的是不是同一份事实? | GlobalContextPacket / 双投影 | 调度漂移、执行跑偏 |
决策层 | 该谁做、要不要追问、要不要改向? | RouterPolicy / ThreadRouter | 误派、漏派、重复派 |
结果层 | 做完的结果怎么被所有人一致理解? | SaResultEnvelope / 产物坐标 | 正文有、产物没、失败说两遍 |
资源层 | 沙箱、端口、发布窗口怎么排队与隔离? | SerialExecutor / 租约与队列 | 并发写冲突、长时间等待 |
改造前,Router 与 Worker 各自拼上下文:Router 读候选清单,Worker 读会话消息。两边都只掌握局部事实,于是出现两类漂移——Router 把任务派给一个已经离线或排队很深的执行体;Worker 在不知道“项目已经做到第三步”的情况下从头再来。
GlobalContextPacket 的做法是:同一份事实只装配一次,然后按角色裁剪投影。它不携带聊天正文,只携带可核对的事实。
public record GlobalContextPacket(
int version, long asOf,
Map<String,Object> identity, // 租户 / 用户 / 入口画像
Map<String,Object> project, // 项目状态 / 进度 / 未完成任务
Map<String,Object> thread, // 线程绑定 / 主执行体 / 节点
Map<String,Object> task, // 本次任务(派发时补)
List<Map<String,Object>> workers, // 执行体运行态
List<Map<String,Object>> artifacts, // 产物坐标
Map<String,Object> clarify, // 待用户回答
Map<String,Object> constraints // 超时等硬约束
) { public static final int VERSION = 1; }
两个投影的边界非常清楚:
第一,只读。装配过程不触发任何写操作。依赖缺失时返回最小包,绝不阻断决策与派发。这保证了“上下文增强”不会变成新的故障点。
第二,可降级。整个能力由一个开关控制:ooder.superagent.context.packet-enabled。关闭后 Router / Worker 都拿不到事实包,行为逐字回落到改造前。灰度与回滚成本极低。
第三,有预算。Router 文本硬上限 2000 字符,超出直接截断并标注“全局上下文已截断”;候选执行体最多渲染 12 条。提示词不是越长越好,预算是硬约束。
事实包如何真正进入执行体的提示词?链路只有四跳:
WorkerTask.contextPacket
→ A2A request.parameters["contextPacket"]
→ 执行端 ChatContext 变量 _globalContextPacket
→ PromptEngine 追加 systemPrompt(未注入则零行为变化)关键设计是“不读也不影响旧行为”。执行端可以按需消费,不消费也不会报错。这让上下文升级可以按 Worker 灰度推进,而不是一次性全量切换。
实际界面(2026-11-14 实测):左侧会话列表承载“线程”语义——同一个会话就是一个项目线程。线程绑定决定了哪些请求可以直通、哪些必须回到 Router 重新决策。
改造前,同一次派发在四个通道里各有一套结果表示:助手正文、A2A 协作气泡、会话 A2A 台账、运行台账 sa_task。前端按通道分别消费,于是必然出现不一致:正文有结论、产物横幅为空;失败原因一个通道说“超时”、另一个说“执行体未完成”。

public record SaResultEnvelope(
String taskId, String conversationId,
String agentId, String agentName,
String state, // SUCCEEDED / FAILED / TIMEOUT / UNDELIVERABLE / WAITING_HUMAN
String humanText, // 用户可读结论
List<ArtifactRef> artifactRefs,
ErrorInfo error, // code + 用户原因 + 处理动作 + 是否系统错误
ClarifyInfo clarify, // 一次只问一个
SteerInfo steer, // 改成 / 换成 / 不是这个
String source, // superagent
boolean suppressA2aBubble,
long asOf
) { }其中 ArtifactRef 只放坐标,不放内容:
record ArtifactRef(String id, String name, String url,
String path, String type, List<String> actions) { }这是“桥接”的核心原则:大对象走坐标,消息只传引用。产物写入 VFS / 对象存储,信封里只带 vfsPath、类型与可执行动作(下载、编辑)。这样既避免了大字段在消息总线上传输,又让前端可以按同一个坐标去取权威内容。
错误码到用户话术的映射不是随手写的,而是三元组:userCause(发生了什么)、userAction(你该怎么做)、system(是不是系统问题)。例如:
错误码 | 用户原因 | 建议动作 | 归类 |
|---|---|---|---|
TIMEOUT | 等待智能体回复超时 | 稍后重试;反复出现则转技术智能体 | 系统 |
UNDELIVERABLE / A2A_UNAVAILABLE | 本次协作没有送达到目标智能体 | 稍后重试;反复出现则转技术智能体 | 系统 |
REQUIRES_INPUT | 缺少必要信息 | 补充缺失信息后重试 | 业务 |
DOMAIN_FORBIDDEN / AUTHZ | 当前入口没有这项业务的办理权限 | 从对应业务入口发起,或联系管理员 | 业务 |
SUBJECT_MISSING | 登录身份缺失或已过期 | 重新登录后再试 | 业务 |
还有一个细节:原始 message 只在“像人话”时才采用——长度不超过 120、包含中文、不含 Exception、不含 16 位以上裸 ID。否则统一给“本次协作未能完成”。技术细节不上屏,但技术面仍然保留原始原因。
suppressA2aBubble 解决的是渠道重叠:聊天链路已经有助手正文承载失败,A2A 侧就不要再冒一个气泡;而 Trigger 这类没有助手正文的链路传 false,必须保留气泡。一个布尔字段,消灭了“同一个失败说两遍”。

实际界面(2026-11-14 实测,已圈选):表单智能体返回的不是一段散文,而是可核对的结构化结果。图中标注:① Worker 标识与结论;② 结构化产物字段对齐表(实体 employee、模式 single_table_crud、4 个字段全部匹配)。这正是 humanText 与 artifactRefs 分离的价值——结论给人看,坐标给系统用。
类型 | 方向 | 承载内容 | 落地形态 |
|---|---|---|---|
任务指令 | Router → Worker | content / operation / 约束 / contextPacket | A2A request |
状态回执 | Worker → Router | ACCEPTED / RUNNING / 进度 / 失败码 | TASK_STATUS / TASK_RESPONSE |
产物 | Worker → Router | 坐标 + 摘要(不含大字段) | SaResultEnvelope.artifactRefs |
追问 | Worker/Router → Human | 一次只问一个结构化问题 | clarify + WAITING_HUMAN |
改向 | Human → Router → Worker | “改成 / 换成 / 不是这个” | steer directive |
系统 | 双向 | 心跳、水位、告警、诊断 | Watchdog / Ops |

ThreadRouter 用三组词表把用户输入分成三类,优先级严格:
此外还有一组“跨线程意图词”(另一个、上次那个、所有任务、改主意……),命中即不允许直通,必须回到 Router 重新决策。这些规则看起来朴素,但它们把“用户改主意”与“用户继续当前任务”区分开了——这是多轮协作最容易出错的地方。

实际界面(2026-11-14 实测):当意图存在歧义时,系统不猜,直接给结构化选项并进入“等待用户确认选择”。这就是 ClarifyInfo 的 UI 形态——一次只问一个,选项可点,超时可回退。

实际界面(2026-11-14 实测):用户消息发出后,界面出现“表单智能体”标识与“任务步骤:发送请求…”。这是 Router 完成决策、A2A 已发出的可见证据;Worker 返回后再由信封统一收口。
重复决策、重复派发的根因是同一会话被并发处理。SerialExecutor 用最朴素的 actor 语义解决它:
public synchronized void execute(Runnable task) {
tasks.offer(() -> {
try { task.run(); } finally { scheduleNext(); }
});
if (active == null) scheduleNext();
}同一会话的事件严格按提交顺序执行,会话之间通过共享线程池并行。这样既保证了单会话的一致性,又没有牺牲全局吞吐。
SuperAgentWatchdog 扫描超过阈值未推进的非终态任务,结合协议状态与线程绑定给出“卡在哪、下一步查什么”的诊断。它只读,不重派、不改状态——自动重派在没有幂等保证时是灾难。
协议状态 | 诊断 | 建议 |
|---|---|---|
NONE | 协议侧无记录:未发出,或仅本地占位 | 检查 A2A 连接、目录 nodeId、能力驻留 |
PUBLISHED / ACCEPTED | 已投递/已受理但无 TASK_RESPONSE | 目标 Worker 可能离线、排队或执行中 |
SENT | 仅入发送队列 | MQTT 未连接或 PUBACK 未达 |
RUNNING + Thread=ASSIGNED | 任务已运行但绑定未推进 | 绑定状态机存在缺口 |
层 | 内容 | 稳定性 | 预算 |
|---|---|---|---|
系统层 | 角色、边界、禁止事项 | 高(可缓存) | 固定 |
编排层(Router) | 任务拆解、worker 选择、失败策略 | 中 | ≤2000 字符 |
执行层(Worker) | 域知识、产出 schema、工具清单 | 中 | 按域分配 |
事实层 | GlobalContextPacket 投影 | 低(每轮变) | 硬上限 + 截断 |
约束层 | 输出 JSON Schema、必填字段、禁止跨域写 | 高 | 固定 |
Router 的提示词只做一件事:决定谁来做。它不执行业务,也不允许编造事实。
你是 Ooder SuperAgent 的 Router(调度层),只负责决定请求该由谁处理,不执行具体业务。
只输出 JSON,不要输出任何解释。字段:
{"decision":"DIRECT_ANSWER|ASSIGN_WORKER|STEER_THREAD|CLARIFY|IGNORE",
"agentId":"候选清单中的 agentId,或 null",
"reply":"给用户的简短回复,可 null",
"steer":"改向时下发给执行体的新要求,可 null",
"clarification":"需要用户补充的信息,可 null",
"reason":"决策依据,一句话",
"confidence":0.0-1.0}
约束:agentId 必须来自候选清单;不确定就选 CLARIFY。
STEER_THREAD = 用户要调整**同一任务**的方向(如“改成方案B”),沿用当前执行体重下任务;
若提供了【租户项目账本】等背景,只用于判断该派给谁,不要臆造未提供的事实。
若提供了【执行体运行态】,可参考其在线状态、所在节点与未完成任务数;
目标离线或明显过载时不要硬派,选择 CLARIFY 并说明原因(如“目标执行体当前不可用/繁忙”)。
不得编造未在清单中的执行体、负载或资源状态。模型实际收到的用户提示词由 Router 拼接,结构固定为“背景 → 用户输入 → 会话坐标 → 候选清单”:
【当前项目】conv-20261114-xxxx | RUNNING | 35% | 未完成 2
【当前线程】PRIMARY / ASSIGNED | 主执行体 form.agent @ node-8124
【执行体运行态(在线/节点/未完成任务/能力)】
- form.agent | 在线 | node-8124 | 未完成 1 | form.modeling,form.convert,form.launch
- finance.agent | 在线 | node-8017 | 未完成 4 | finance.bankstatement,finance.voucher
用户输入:
把这张员工表建成表单
conversationId=conv-20261114-xxxx
sceneId=form-modeling
entryProfile=rt:form
候选执行体:
- form.agent | name=表单智能体 | node=node-8124 | caps=form.modeling,form.convert,form.launch
- finance.agent | name=财务智能体 | node=node-8017 | caps=finance.bankstatement,finance.voucher注意最后一段:项目账本与执行体运行态都来自 GlobalContextPacket 的 Router 投影,而不是模型自己“想象”的。模型看到的负载、在线状态、节点,都是可核对的事实。
三个关键约束:
decision 只能取五个值,避免自由发挥;agentId 必须来自候选清单,服务端会二次校验;LLM 路径默认关闭(router.llm-enabled=false)。即使打开,也有三道保险:
agentId 不在候选清单 → 转 CLARIFY,绝不臆造目标;规则路径本身也不是摆设:显式 @ 命中直接派发;锁定场景或指定场景本地直答;命中续作意图且账本有历史任务时,确定性派回最近执行体。这条路径无网络开销、结果可复现,是 LLM 不可用时的保底。

实际界面(2026-11-14 实测,已圈选):用户点名一个不存在的执行体时,Router 不猜、不硬派,直接返回全局候选清单并说明“未找到”;下方任务步骤同步显示“请求已发送 / 正在理解需求”。图中标注:① Router 返回的候选清单;② 任务步骤状态。这正是“候选白名单 + 不确定就 CLARIFY”的可见形态。
Worker 侧不是一段提示词,而是按固定顺序装配出来的:
prompt = 场景模板.replace("{context}", context)
+ THINKING_LANGUAGE_RULE
+ "\n\n场景特定指引:\n" + scene.buildSystemPrompt(context)
+ DECISION_ENTRY_GUIDE // 仅 rt:decision 入口
+ 面板上下文 // sceneContext
+ 全局事实包块 // _globalContextPacket
+ 沙箱上下文 // 项目元信息 / 运行时状态其中“思考语言规则”和“事实包块”由统一出口注入,不依赖各执行体自觉:
【思考与输出语言】
- 推理/思考过程(reasoning)必须使用简体中文,不要用英文思考。
- 最终回答也使用简体中文;仅代码、命令、路径、报错原文、专有名词保留原语言。【全局上下文(SuperAgent 事实包)】
- 租户/用户: default / user-001 | 入口: rt:form
- 项目: conv-20261114-xxxx | RUNNING | 35% | 未完成 2 | 员工表转表单
- 本次任务: task-7f3a | 执行体 form.agent | 节点 node-8124 | 状态 RUNNING
- 约束: timeoutMs=120000, deliveryTimeoutMs=15000
(以上只用于保持与项目一致;不得修改项目状态,缺信息就问。)最后一句是防越权的关键:模型可以读项目状态,但不能改;缺信息就问,而不是猜。这也是 Worker 投影里 workers=[]、clarify.pending=false 的原因——执行体不需要看到调度信息,也不应该替 Router 发起追问。
以银行流水转换执行体为例,其默认系统提示词不是泛泛的“你是助手”,而是把工具顺序、风险口径、交付格式写死:
你是OODER平台的专业资金流水转换助手。你的职责是帮助用户将银行流水自动转换为资金日报格式。
工具调用顺序(必须严格遵循):
1. 当用户上传了银行流水文件时,先调用 bank_statement_parse 工具解析流水
2. 拿到解析结果后,调用 bank_statement_convert 工具执行字段映射与项目分类
(贷方→收款金额、借方→付款金额)
3. 根据转换结果,向用户输出转换报告、统计汇总和下载链接
★ 列健壮性与风险检查策略:
1. 解析结果附带 warnings 时,向用户提示缺失列并列出识别到的列名/表头,
请用户确认或补充列映射;冗余列自动忽略,不中断转换。
2. 转换结果附带 risks(双向金额并存、单笔≥100万疑似单位错误、
常规科目金额明显异常、有对方户名无金额疑似列错位)时,
必须在报告中显著列出风险条目与行号,建议核实后再入账。
3. 转换完成后输出“📌 下一步提示:您可以…”横幅,给出 3-4 条后续建议。
★ 强制下载链接要求:
只要生成了新的转换/筛选/重查结果,最终回复**必须**附带最新 Excel 下载链接;
地址必须使用工具返回的真实 downloadUrl,禁止输出“下载地址”等占位符;
若本轮没有可下载产物,明确写“(本次无新生成的可下载文件)”。
★ NLP 兜底机制:
条件不明确或存在歧义时,主动向用户确认关键条件,而不是臆测执行。这段提示词体现了三个原则:工具顺序确定、风险口径确定、交付格式确定。模型可以决定“怎么表达”,但不能决定“跳过哪一步”。

实际界面(已脱敏):资金日报场景中,执行体返回“共 5 家公司”的结构化查询结果,包含流水笔数与明细行数;第一列与银行账号列已用 ×××× 覆盖,保留统计口径与结果形态,便于核对“结论 + 数字”的交付方式。
Router 看到的“能力”不是模型的自我描述,而是执行体注册的 op 清单。表单执行体注册了 10 个 op,这张表同时是路由依据与验收依据:
op | 用途 | 典型调用方 |
|---|---|---|
ping / health | 连通性与健康探针 | Directory / Watchdog |
capabilities | 返回能力目录(与注册清单同源) | Router 候选汇总 |
modeling | 按模式建模,返回产物摘要 | 主能力:自然语言建表单 |
convert | 外部表单 → 统一 formSchema → 字段对齐报告 | 导入/迁移场景 |
launch | 启动或复用表单流程实例 | @智能体 / 流程型执行体 |
query / status | 查询会话绑定与暂停实例状态 | 续作 / 人工接管 |
list / download | 产物查询与下载 | 产物横幅 / 审计 |
注意:能力契约里没有“自由对话”。执行体只接受有限的 op,这既缩小了攻击面,也让结果可验证。
在讨论资源调度之前,先看代码里已经生效的几组参数——它们其实就是最朴素的资源治理:
参数 | 默认值 | 作用 |
|---|---|---|
superagent.executor-size | 4 | 会话执行线程数,决定全局并行度上限 |
task.max-per-user | 3 | 单用户并发任务上限,防单点刷爆 |
task.timeout-ms | 120000 | 任务总超时 |
task.delivery-timeout-ms | 15000 | 跨节点投递确认窗口,超时立即 UNDELIVERABLE |
session.max-active | 500 | 活跃会话上限 |
session.idle-ms | 86400000 | 会话空闲回收 |
其中“投递确认窗口”是一个很值得借鉴的设计:远端节点进程不在时,MQTT 发布本身会成功,旧行为只能空等到 120 秒。加入 15 秒确认窗口后,收不到受理回执就立即失败——把“空等”变成“快速失败”。
沙箱与线上服务排期的本质是有限资源的分配问题。建议统一建模为:
Pool(type) → Slot[] // 每类资源一个池,池内是槽位
Slot { id, state, lease, owner, labels }
Lease { leaseId, taskId, ttl, renewAt, preemptible }三类典型资源:

闸 | 机制 | 解决什么 |
|---|---|---|
命名空间 | tenant / flow / task / worker 四级前缀 | 不同租户、不同任务的产物互相覆盖 |
锁分级 | 对象锁(单产物)、域锁(表单/财务)、流程锁(实例级) | 跨对象并发写冲突 |
乐观锁 | 写前校验 version,冲突则重读重试 | 长事务下的丢失更新 |
幂等键 | 同一逻辑操作唯一键,重复提交合并 | 用户重复点击、消息重投 |
写集预检 | 执行前比较读集/写集,冲突则排队或拆分 | 两个 Worker 同时改同一实体 |
工程边界
当前版本以会话级单写者(SerialExecutor)、线程绑定、任务超时与投递确认、看门狗诊断为主;资源池、租约与抢占按上述协议在后续版本逐步接入。把边界写清楚,是为了让每次扩展都有明确的责任面。

组件 | 扩展方式 | 一致性要求 |
|---|---|---|
Router Cluster | 无状态水平扩展 | 共享 TaskBoard,单写者 CAS |
Worker Pool | 按域独立扩缩容 | 任务幂等 + 租约 |
Resource Scheduler | 单主 / 选主 | 强一致,防双主分配 |
Message Bus | 分区 + 持久化 | 至少一次 + 去重窗口 |
上下文快照 | 对象存储 / VFS | 最终一致,版本号收敛 |
阶段 | 目标 | 关键动作 | 验收 |
|---|---|---|---|
P0 | 事实与结果归一 | GlobalContextPacket + SaResultEnvelope 全链路透传 | 正文、产物、台账口径一致 |
P1 | 资源与隔离 | 租约调度器、命名空间、锁分级、幂等键 | 并发写零覆盖,长任务不饿死 |
P2 | 分布式与容错 | Router 无状态化、心跳、重派、断点续跑 | 单节点故障不影响整体 |
P3 | 智能编排 | 能力卡入提示词、全局任务图、失败自动改道 | 派发准确率与一次成功率提升 |
P4 | 体验与治理 | HITL 卡、关键路径埋点、审计回放、成本核算 | 阶段可见、可诊断、可回放 |
第一,先把“事实”统一,再谈“智能”。上下文包和结果信封看起来不性感,但它们决定了系统是否可解释、可回滚、可灰度。没有这两样,模型越强,漂移越大。
第二,让 LLM 做它擅长的事,把不确定性关进笼子。Router 只输出枚举化 JSON,目标必须在候选白名单内,解析失败一律回落规则路径。模型可以建议,但不能拍板。
第三,资源治理的本质是“承认有限”。线程、沙箱、端口、发布窗口都是有限的。租约、队列、幂等、单写者,这些老掉牙的招数,在多智能体系统里一个都不能少。
一句话总结
SuperAgent 的升级不是“让 Agent 更聪明”,而是让一群 Agent 共享同一份事实、同一套坐标、同一句人话。做到这三点,协作才从“看起来在配合”变成“真的能交付”。
代码基线(ooder-pro / net.ooder.studio.chat.superagent)
GlobalContextPacket.java · GlobalContextAssembler.java · SaResultEnvelope.java · SaResultAssembler.java RouterPolicy.java · ThreadRouter.java · WorkerTask.java · A2aWorkerDispatchAdapter.java SerialExecutor.java · SuperAgentWatchdog.java · SseReplyAdapter.java · SuperAgentProperties.java 前端消费:static/shared/chatnext-base/ui/ArtifactBar.js · PromptEngine.java
界面截图来自测试环境实录;架构图为原创 SVG。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。