单 Agent 处理复杂任务时,上下文上限和职责混乱很难避免。当任务规模扩大、需要多个专职代理时,简单的顺序派发不够用——需要一个真正的执行引擎,而不是一串 await 调用。Ultracode 的重写把这个执行引擎从写死的轮次评委模式升级为通用、分阶段、可观察的编排系统。
为什么需要重写
原有 Ultracode 的核心逻辑是循环评委投票:单个 worker 代理做任务 → 固定的 9 个 judge 代理投票 → 收敛检测 → autoFix 落盘。这个模型有三个硬伤。
其一,职责混乱。所有代理都用同一套 prompt 模板,judge 和 worker 没有差别的实现,评委们也不知道自己要对抗什么。结果是许多时间花在重复评判已稳定的地方,而不是聚焦新引入的变化。
其二,并发失控。原架构是串行的:worker 做完 → 再起 9 个 judge。如果拆成了 10 个子任务,那就是 10×(1 + 9)= 100 个 agent 跑,没有并发上限的制约,大任务轻易触发网关限流。
其三,观察盲区。系统只在所有 judge 投票完后汇报结果。长任务跑到一半,用户看不见进展、无法判断是真的在做还是卡住了,也没办法中途调整策略。
三个核心决策
重写围绕三个硬需求展开。
第一,并发必须有上限。 没有信号量的话,一个大的代理分派会立刻把网关打满 429,然后不得不在代码里加退避——但那是被动的、出了问题才补。正确做法是从一开始就设定并发的快慢桶:默认 10 并发(可调 5 ~ 15),队列存 100 个任务,当网关响应 429 时指数退避(100ms → 5s 重试,最多 3 次)。这样既防止了失控的并发喷发,也保证了任务不会丢失。
第二,失败必须能隔离。 一个子任务失败不应该拦截整个编排流程,但要能标记为不可用、向下游传播。比如拆解阶段有个 worker 崩了,应该照样让其他 worker 继续跑,最后的综合阶段在做决策时看到「该 worker 失败」的标记,转入降级策略。这要求每个 agent 的执行结果都被独立记录:成功、失败、超时等状态都是数据,而不是异常。
第三,执行过程必须可观察。 不是只返回最终结果,而是全程发事件:某阶段开始 → 该阶段有 N 个代理排队 → 某代理开始跑 → 它完成了(消耗多少 token、多少成本、耗时几秒)。中间可以实时看树形结构,长任务跑到一半时能判断是否要停、是否有机会加速。
从编排计划到分阶段执行
重写后的架构围绕一个数据结构:编排计划(Plan)。
interface Plan {
phases: Phase[];
}
interface Phase {
title: string;
agents: AgentTask[];
}
interface AgentTask {
id: string;
label: string;
role: 'worker' | 'judge' | 'synth';
prompt: string;
inputsFrom?: string[];
}
计划定义了多个阶段,每个阶段内有若干代理并行执行。key point 是 inputsFrom——如果某个 judge 要对抗式检查前面三个 worker 的产出,它的 inputsFrom 就引用那三个 worker 的 id。引擎执行时会把前序输出自动拼接到 prompt 前,规则很简单:只能引用更早阶段的输出,否则计划非法。
最终产出规定为最后一个阶段所有代理的输出拼接。通常最后一个阶段是单个 synth(综合器),所以最终答案就是它的输出。
执行引擎的三层保证
新的执行引擎 runPlan 接收这份计划和一个回调函数,逐阶段推进:
- 校验阶段:计划的 id 要唯一、
inputsFrom只能指向更早阶段的存在 id、每个阶段至少有一个代理。非法计划直接返回失败,不浪费 API 调用。 - 并发执行:每个阶段启动后,引擎用一个信号量控制并发。假设有 5 个代理要在某阶段跑,并发限是 10,那么 5 个直接入队并行。前面某个代理完成后,队列里的下一个立刻启动,永远不超过 10 个。每个代理在启动时发
agent_start事件,完成时发agent_end事件(包含成功与否、消耗、耗时)。 - 护栏检查:每启动新代理前检查三条红线——已用总成本超预算、已跑过的代理总数超上限(200)、墙钟超 4 小时。任一触发就停,返回已累计的结果和停止原因(
done/budget/max_agents/timeout)。
这样设计的好处是,即便一个代理失败,也不卡管线——该代理的 ok:false 被记录,下一代理照样启动。最后发现有人失败时,综合器可以选择降级方案(比如忽略这个 worker 的产出、或者用备选逻辑)。
失败隔离与结果合并
每个代理的执行结果是一个 AgentRunResult 对象:
interface AgentRunResult {
ok: boolean;
text: string;
costUsd: number;
tokensIn: number;
tokensOut: number;
turns: number;
elapsedMs: number;
}
引擎在 outputs 字典里按代理 id 存下所有输出文本,无论成功失败。如果某个 worker 失败了(比如网络超时、模型拒绝),它的 ok:false 和错误信息文本被记录;下游的 judge 如果设了 inputsFrom 引它,拿到的是「失败」的标记而不是代码。synth 在综合时看到这个标记,决定是否落地降级逻辑。这样避免了一个环节的失败导致后续环节无法启动的僵局。
计量在引擎层统一累加:每个代理完成后,它的 token 消耗、成本、耗时都加进全局统计。最终 PlanRunResult 返回的 totalCostUsd / totalTokensIn / totalTokensOut 是所有代理的总和,这个数字在实时树的统计行里显示。用户能看到这一轮 Ultracode 到底花了多少钱、消耗了多少 token。
AI 编排器:从任务到计划
光有执行引擎不够,还需要一个组件把用户的任务自动转成合法的计划——这就是 AI 编排器。
编排器是一个简单的 LLM 调用:告诉模型「我需要编排一个多代理方案解决这个任务」,模型回复一份 JSON Plan。它决定什么时候拆多阶段、什么时候加 judge、什么时候单 worker 就够了。核心要点:
- 简单任务(比如"翻译这段文本")→ 单 phase、单 worker,不过度工程化。
- 复杂任务 → 典型三阶段:Phase 1 拆解并行(多 worker)→ Phase 2 对抗验证(judge 们各自检查)→ Phase 3 综合(synth 合并)。
- 每个 judge 的
inputsFrom指向前面的 worker,每个 synth 的inputsFrom指向全部前序输出。
Role 在执行时有默认映射。worker 代理给全工具、maxTurns 设 16,让它有充分的时间思考和调用工具。judge 代理不给文件工具(纯推理,避免在主目录空转浪费时间),maxTurns 设 4 就够(只需审查、不需实现)。synth 不给任何工具(纯思考与综合),maxTurns 也是 4。这个映射可以在配置里调整,但默认值经过真机验证,不浪费 token 又保证完成度。
解析时有三道防线。第一,如果 LLM 完全乱来(乱文本、无 JSON、字段缺失),fallback 到「单 phase 单 worker」。第二,如果 Plan 结构合法但逻辑不符(id 重复、inputsFrom 引同阶段的输出、总代理数超 200),validatePlan 拦下来,转 fallback。第三,编排器代理本身崩了(返回 ok:false),直接 fallback。永远不抛异常、永远返回一个合法 Plan——这是对聊天服务的基本承诺。
Fallback 计划取用户任务的首行作为标签(约 24 字截断),生成一个单 phase 单 worker 的方案。即便编排器完全失败,系统也能降到「让单个 worker 代理尽力而为」的状态,至少能产出一个结果。这个降级极端简单,但对容错性至关重要。
单代理执行器的文本捕获修复
升级执行引擎时顺手修了一个隐藏的 bug:空文本。
之前的架构里,SDK 的消息循环会从 result 类消息里只读成本和轮数,但丢掉了最终文本字段。在聊天里没事,因为文本通过 onText 事件流式积累。但在 Ultracode 的低 maxTurns 子代理里(比如只跑 3 轮的 judge),最终答案常常只在 SDK 返回的 result 对象的 text 字段里,一旦被丢就变成空字符串,后续评委拿不到内容。
修法是扩展 SDK message 的消费方式:新增一个 finalText 字段(从 result 消息的 text 取出),新执行器的文本获取规则是「优先用流式累加的 assistant 文本(非空),否则用 finalText」。两层保险,确保没有空文本掉落。
实时执行树与折叠视觉
执行过程中,每个事件都被渲染成一棵树:顶部脉动蓝点 + 「Ultracode」+ 实时统计行(已用时间 · 代理数 · tokens · 成本);下面是若干折叠的阶段分组,每个阶段显示代理数和进度圆点;展开时是一张表,每行一个代理(状态图标 + 名字 + tokens + 工具调用数 + 成本 + 耗时)。
运行中,running 的行背景高亮蓝色,queued 的行显「排队」,done 的行打 ✓,failed 的行打 ✗。进度圆点动态着色,一眼看本阶段的完成度。任务跑完后,树自动折成一行摘要(「Ultracode · 7 代理 · 142.6k tokens · $0.21」),点击展开看完整细节。
这一层的数据从执行引擎的事件流 → 聊天的 IPC → 前端组件。组件本地维护计时(用 setInterval 每秒更新已用时间),不依赖轮询服务端。组件卸载时清理 interval,避免泄漏。
并发与失败的权衡
这套系统在三个坐标轴上做了权衡。
其一是吞吐 vs 限流。并发默认 10,可调到 5(保守) ~ 15(激进)。10 这个数字经验得来:足够快(百代理任务不会太慢),又不会轻易撞 429。网关如果返回限流,退避队列自动延缓重试(100ms 起,指数增长到 5 秒,最多 3 轮),不需要外层干涉。这样既防止了脉冲式的并发喷发把网关打崩,也保证了排队的任务不会丢失。
其二是自治 vs 安全。主代理可以派任意数量的子代理、动态赋予工具权限,但 danger-guard(防 rm -rf 等灾难命令)对所有子代理强制生效,不可绕过、不可被赋权赋进去。这是不可协商的红线。某个 judge 即便被指示「删掉这个目录」,系统也会直接 deny,日志记录尝试,让主代理知道。
其三是完美 vs 可用。原架构追求收敛一致的答案(所有 judge 投票通过),这导致许多任务无限收敛。新架构则是分阶段推进:每个阶段内,所有并行的代理跑完后再进入下一个阶段,不需要等待它们"一致"。如果最后发现有代理失败,synth 在综合时看到失败标记,主动降级(比如跳过那个输入、用其他输出补充)。终止条件是「超轮数(500)/ 超成本 / 超墙钟(240 分钟)」,保证任务总能在有限时间内完成。这三条护栏是硬边界,任一触发立刻停,无论是否"完美"。
测试验证与真机硬门槛
新引擎在单元测试里用 mock executor 验证:
- 编排计划的校验:id 唯一性、
inputsFrom只能引更早阶段、无环依赖等;非法计划直接 reject。 - 并发与信号量:手动注入延迟的 executor,验证同时跑的代理数永不超过并发上限;队列机制正确。
- 护栏触发:构造会超成本、超代理数、超超时的场景,验证在对的时刻停止、返回对应的 stopReason。
- 失败隔离:某代理 ok:false,后续代理照样启动;最终输出字典里该代理的值是错误信息而不是空。
- 事件流:逐代理验证 agent_start 和 agent_end 事件的顺序和内容。
但光有单测不够。真机验收要求:手写一份单 phase 单 worker 的最小计划,喂给 runPlan 走真实的 SDK 调用。断言返回的 finalOutput 非空(确认空文本 bug 已修)、事件链完整、成本与 token 数据透出。这是硬门槛——不过此测试,不算完成。
一份具体的编排例子
假设任务是「用 TypeScript 各写 3 个独立纯函数(isPalindrome / isPrime / fibonacci),对抗式验证后综合为一份带测试的最终代码」。
编排器生成的计划看起来像:
{
"objective": "实现三个算法函数并通过对抗验证",
"phases": [
{
"title": "拆解并行实现",
"agents": [
{"id": "w1", "label": "实现 isPalindrome", "role": "worker", "prompt": "..."},
{"id": "w2", "label": "实现 isPrime", "role": "worker", "prompt": "..."},
{"id": "w3", "label": "实现 fibonacci", "role": "worker", "prompt": "..."}
]
},
{
"title": "对抗验证",
"agents": [
{"id": "j1", "label": "检查 isPalindrome", "role": "judge", "inputsFrom": ["w1"], "prompt": "..."},
{"id": "j2", "label": "检查 isPrime", "role": "judge", "inputsFrom": ["w2"], "prompt": "..."},
{"id": "j3", "label": "检查 fibonacci", "role": "judge", "inputsFrom": ["w3"], "prompt": "..."}
]
},
{
"title": "综合",
"agents": [
{"id": "s1", "label": "整合最终代码与测试", "role": "synth", "inputsFrom": ["w1", "w2", "w3", "j1", "j2", "j3"], "prompt": "..."}
]
}
]
}
执行时:Phase 1 三个 worker 并行跑(互相独立)→ Phase 2 三个 judge 并行验证(各自消费一个 worker 的产出)→ Phase 3 synth 综合所有输入产出最终答案。如果某个 worker 失败了(比如网络超时),judge 仍继续验证其他 worker,synth 在综合时看到「w1 失败」的标记,决定采用降级方案。
外部集成:思考档位与聊天路由
在聊天层,Ultracode 和普通回复一样,是一条消息的「思考档位」。用户在 composer 选普通 / 深度 / Ultra(对应 normal / extended / ultracode),送出消息时聊天 endpoint 判断档位,如果是 Ultra 就走编排管线。
管线是:接收任务 → 编排器生成 Plan → 执行引擎 runPlan 按阶段跑 → 事件流实时推到前端树 → 完成时最后的 synth 输出成为聊天的消息内容,落库作为一条 message。整个过程对聊天是透明的——用户看到的只是一条消息,但这条消息的产生过程被完整记录了(每个 phase、每个 agent、每个事件),支持稍后点击树节点回放具体某个代理的输出。
这个设计的好处是,Ultracode 不再是一个独立屏幕,而是聊天的一个模式。消息历史、搜索、导出等聊天功能自动支持 Ultracode 的任务——不需要单独维护一套 /ultracode 屏的数据模型。
从原架构到新架构的演变
回看原有的评委-轮次模式和新的分阶段编排,区别在于谁决策编排拓扑和何时收敛。
原架构是「一个 worker + 9 个评委 + 收敛循环」的固定管线。优点是逻辑简单、有明确的投票机制;缺点是不敏捷——即便 worker 搞定了,还要等 9 个 judge 都投票,任何一个 judge 的异议都可能推进新一轮,导致无限循环。
新架构则是「LLM 编排器规划拓扑、通用引擎执行、阶段内并发、失败不拦管线」。编排器看到复杂任务时自主拆多 phase(比如 10 个并行 worker),而不是冗长的轮次。即便某个 worker 失败,judge 照样验证其他成功的 worker,synth 在综合时主动决定降级。这样既不浪费时间在已稳定的输出上重复评判,也保证了管线的吞吐。
从成本角度,新架构潜在地消耗更少——同样的任务规模,原架构是 1 worker + 9 judge × N 轮,新架构是 N worker + N judge(对抗验证)+ 1 synth,通常少好几倍。真机上见过单个任务从原架构的「200+ 代理跑了 6 轮」降到新架构的「30 代理一轮过」。
仍未解决的边界情况
设计中也有已知的限制。其一,子代理递归不支持——即子代理再派下一层子代理。SDK 的 Task 工具在子代理的上下文不可用,这是 SDK 层的约束,引擎无法绕过。实践中这个限制影响不大,因为 Ultracode 的典型用途是「任务 → 多 worker 并行 → 对抗验证」的三层,很少需要递归。
其二,动态编排调整。计划一旦生成后就是固定的,运行中不能动态插入新 phase 或新 agent。如果某个阶段的结果出乎预料,主代理不能中途改变后续阶段的代理配置。这是为了保证计划的确定性和可回放性——一份计划应该在相同的输入下产出相同的执行树。未来如果真需要这个能力,可以在编排器层面实现「多轮编排」(第一轮生成初步计划、看结果后再微调)。
其三,编排器本身的稳定性。LLM 产出畸形 Plan 的风险永远存在,fallback 机制可以兜底但不能杜绝。未来可以考虑对编排器的输出做更激进的验证、甚至让编排器自己生成测试 case 来验证拓扑设计。但这超出了 SP 子项目的范围。
后续迭代方向
这套引擎的设计目标是「通用」,所以留了扩展点。首先是编排策略——现在编排器是简单的「LLM 一次调用」,未来可以升级为「判断复杂度 → 按复杂度选用编排模板 → 微调参数」,甚至「用历史成功案例做 few-shot 引导」。其次是收敛检测的进阶——现在的判据是「连续 N 轮无新问题」,太粗糙。可以升级为「按问题类型(语法 vs 逻辑 vs 性能)分别统计、不同类型不同收敛阈值」。第三是成本可见与预算控制——现在成本相加没有细颗粒度控制(只有绝对上限),可以做分阶段的预算、或者基于历史任务的成本预测。
但这些都是后话。眼下重要的是让核心引擎的三个决策——并发上限、失败隔离、实时可观察——都落地并验证。
■