AgentHub 的核心引擎:AgentLoop 与 ReAct 循环
本文是 AgentHub 后端架构系列的第 3 篇,对照源码
src/agent/loop.ts、src/agent/runner.ts、src/agent/context-builder.ts讲解。前情:02-渠道层与消息总线 讲了消息怎么进站,这一篇讲进站之后发生什么。
引言
一个 Agent 网关收到用户消息后,到底发生了什么?答案不是"调一次 LLM 然后返回"------一次 LLM 调用拿不到实时数据,模型需要先思考、调工具、看结果、再思考,循环几轮才能给出最终回复。这个"思考→行动→观察"的循环就是 ReAct(Reasoning + Acting),而把它包装成"一条消息进、一段回复出"的完整编排,就是本文的主角 AgentLoop。
AgentHub 采用外层编排(AgentLoop)+ 内层推理(AgentRunner) 两层结构:外层负责 system prompt 组装、历史文件块省 token、请求过大兜底、记忆压缩、错误兜底这些横切逻辑;内层只负责"问 LLM → 调工具 → 观察结果 → 再问",直到 LLM 不再要工具为止。还有一点贯穿全文:这一整套对象不是全局单例,而是每个 agent 各自一套(每个 AgentEntry 自带 loop/runner/toolRegistry/contextBuilder/consolidator/provider)。
一、它在架构里的位置
Agent Loop 卡在 GatewayCore 的"实际处理"这一步:
scss
渠道消息 -> 消息总线进站队列 -> GatewayCore.dispatch
│
├─ 找 agent(按 agent_hint 或渠道绑定路由)
├─ 命令路由
├─ 拿会话 + 拿锁
├─ handleAgent ← Agent Loop 在这一层
│ │
│ ├─ 用户消息进历史
│ ├─ reply("处理中...")
│ ├─ entry.loop.process(msg, history, signal, sink) ← Agent Loop
│ ├─ 回复进历史
│ └─ sink 推 done 事件(full_text)出站,不再 reply(回复)
└─ finally: 释放锁 + 排空 Mid-turn Queue
GatewayCore 持有的 processor 类型签名是 (msg, history, signal, sink?) => Promise<string>,实际就是某个 agent 的 AgentLoop.process。第 4 参 sink 是可选的流式事件回调:Loop 跑 ReAct 时把 text/tool_call/tool_result/done/error 事件逐个 await sink(ev) 推出去,GatewayCore 那边的 sink 再把事件转成出站消息------最终回复由 done 事件的 full_text 携带推送,不再走 reply(msg, response)(顺带一个细节:空回复连 done 事件都不推,保持"空回复不回复"的语义)。
这里有个关键的架构决策:GatewayCore 不持有任何全局 loop,而是先通过 AgentRegistry 找到消息对应的 agent(entry),再调 entry.loop.process(...)------用该 agent 自己的 loop。GatewayCore 只管调度(路由、锁、队列、单轮模型覆盖、流式出站),把消息和历史准备好丢给对应 agent 的 processor;怎么从消息和历史产出回复文本,全是那个 agent 的 Agent Loop 的事。
二、为什么要分 Loop 和 Runner 两层
ReAct 循环本身很好写------一个 for 循环而已。难的是循环外面那一圈东西:这一轮要不要先压缩历史?图片要不要转 base64?请求太大怎么降级?出错了怎么兜成用户能看懂的文案?把这些和推理循环混在一起,那个本该精悍的 for 循环很快会被淹没。
所以项目把两层拆开:
| 层 | 职责 | 比喻 |
|---|---|---|
| AgentLoop(外层,编排层) | 编排整个对话轮次:system prompt 组装、历史文件块省 token、请求过大兜底、记忆压缩(Consolidator)、错误吃成兜底文案 | 导演 |
| AgentRunner(内层,推理层) | 跑 ReAct 循环:一次次问 LLM、收到 tool_use 就调工具、把结果喂回去、直到 LLM 不再要工具直接出文本 | 演员 |
GatewayCore 调的是 Loop (外层);Loop 内部用 Runner (内层)跑实际推理。分层的核心理由:ReAct 循环是纯粹的推理逻辑,不该和"这一轮要不要压缩历史""图片怎么处理""请求太大怎么降级"这些横切关注点耦合。收敛之后 Runner 只有几十行,单元测试 mock 一个 provider 就能测"给定 LLM 返回 tool_use,会不会正确执行工具并进下一轮";Loop 的测试则聚焦"413 会不会触发降级""system 摘要会不会折叠进 prompt"。两类逻辑各自独立演进、独立测试。
还有一个实际好处:请求过大兜底是在 Loop 层"重新调用整个 Runner"实现的。如果 Loop 和 Runner 是一层,这种"重跑整个循环"的兜底很难干净地表达。
打个比方:用户发一条消息 = 一场戏。Loop 是导演,决定这场戏怎么排(开演前要不要先压缩历史?演完要不要触发后台记忆整理?);Runner 是演员,在台上反复"思考-行动-观察",直到把戏演完。
三、为什么这套对象是 per-agent 的
AgentHub 经历过一次架构级演进:从"一个全局 agent 实例"变成"多个可配置的 agent 实例并存"(比如一个业务审核 agent、一个 default 通用 agent,各有各的人格/工具/模型/记忆)。这直接改变了 Loop 的持有方式。
旧设计(单 agent 时代) :全局一个 AgentLoop 单例,一个 ContextBuilder,一个 Consolidator,大家共享。
新设计(多 agent 时代) :AgentRegistry 管理所有 agent,每个 agent 加载成一个 AgentEntry,里面是该 agent 专属的一整套运行时对象:
typescript
export interface AgentEntry {
definition: AgentDefinition; // agent.json 配置
state: AgentState; // draft / active / archived
loop: AgentLoop; // 该 agent 自己的 AgentLoop(不再全局单例)
toolRegistry: ToolRegistry; // 该 agent 自己的工具集
contextBuilder: ContextBuilder;// 该 agent 自己的 system prompt 组装器
consolidator: Consolidator; // 该 agent 自己的 L2 记忆压缩器
ctx: AgentContext; // 该 agent 自己的运行环境(路径/护栏覆盖)
provider: BaseProvider; // 该 agent 自己的 LLM Provider(model/provider 槽位均可 per-agent)
providerName: string; // 该 agent 选中的 provider 槽位名
subagents: Map<string, SubagentDefinition>;
sessionRuntimes: Map<string, Map<string, SubagentDefinition>>;
}
这套对象在 AgentRegistry.loadOne(name) 里逐个构建:
csharp
loadOne(name: string): void {
const def = this.store.loadDefinition(name);
const ctx = createAgentContext(name, projectDir, def.file_access, def.shell, def.email);
const toolRegistry = this.buildToolRegistry(def); // 按 def.tools 白名单过滤
const provider = resolveModel(def.model, def.provider); // model + provider 槽位均可 per-agent
const contextBuilder = new ContextBuilder( // 带 agentName + enabledSkills + ctx
join(projectDir, "agents", name), name, def.skills, ctx,
);
const consolidator = new Consolidator( // 阈值可 per-agent 覆盖
provider,
def.memory?.consolidate_at_tokens ?? getConfig().memory.consolidate_at_tokens,
);
const loop = new AgentLoop(provider, toolRegistry, contextBuilder, consolidator, {}, ctx);
// ...存进 this.agents
}
为什么这样设计,而不是共享一个 loop 靠参数区分 agent?因为 agent 之间差异是"深"的,不是"浅"的 :不同 agent 有不同的 SOUL(人格)、不同的工具白名单、不同的 skill、可能不同的模型、不同的记忆压缩阈值、不同的文件访问护栏。这些差异如果全靠调用时传参,Loop/Runner/ContextBuilder 的每个方法签名都要塞一堆 agent 相关的参数,内部到处是 if (agent === ...) 分支。把差异固化进"每个 agent 一套预构建好的对象",调用方只需 entry.loop.process(...),对象内部完全不用感知"我是哪个 agent"------ContextBuilder 构造时就绑定了自己的 agentDir/agentName/enabledSkills,Consolidator 构造时就绑定了自己的阈值。配置的复杂度在加载期一次性消化掉,运行期是干净的。
需要澄清共享的边界:工具的来源池是共享的 。builtinTools / mcpTools 全局只构建一次,每个 agent 的 ToolRegistry 只是从这个共享池里按 def.tools 白名单过滤挂载------不是每个 agent 独立实现一套工具代码。per-agent 的是"这个 agent 能用哪些工具"这份视图,不是工具实现本身。
Provider 也 per-agent 化了,方式是槽位制 :全局配置 llm.providers.<name> 定义多个 provider 槽位(各带 api_key/base_url/model/api 协议),agent.json 用 provider 字段选槽位、model 字段覆盖槽位默认模型,resolveModel(def.model, def.provider) 解析时配错直接抛错、不隐式降级(旧版静默 fallback 会拿错 key/模型,难排查,详见 09-Provider层)。所以不同 agent 可以接不同供应商账号。
四、AgentLoop.process:一轮对话的完整编排
AgentLoop.process 就是 GatewayCore 调的那个 processor。它做的是"一轮对话的完整编排",不是 ReAct 循环本身(ReAct 在 Runner 里)。拆成 7 个动作:
动作 1:开 trace + 触发 onTurnStart 钩子
每轮生成一个唯一 trace id 绑 session_key,Tracer 后面每步都 record,最终拼成完整调用链。onTurnStart 挂了逻辑就跑,没挂就跳过(?. 可选链)。
动作 2:组装 system prompt + 折叠历史里的"摘要"
ini
const system = this.contextBuilder.buildSystem(msg.channel);
const allHistory = history.load(msg.session_key);
const summaries = allHistory.filter(m => m.role === "system").map(...).join("\n\n");
const effectiveSystem = summaries ? `${system}\n\n---\n\n${summaries}` : system;
const nonSystemHistory = allHistory.filter(m => m.role !== "system");
两个关键点:
(a) system prompt 由该 agent 自己的 contextBuilder 组装。 buildSystem(channel) 的产出按顺序拼装:SOUL.local.md(或 SOUL.md,从该 agent 目录 agents/<name>/ 读)→ 当前时间块 → 该 agent 已启用 skill 的索引 → 用户授权的工作目录 (按该 agent 的有效 file_access 配置)→ platform-policy/<channel>.md(也从该 agent 目录读)。这里体现三处 per-agent 化:一是 SOUL 从 agent 自己的目录读,不同 agent 人格不同;二是 platform-policy 也 per-agent 化,不同 agent 在同一渠道可以有不同安全指令;三是 skill 索引按 enabledSkills 清单查找(不扫描全局 skills/ 目录),查找顺序是"agent 专属优先(skills/<agentName>/<skillName>/)> 全局(skills/<skillName>/) "------同一个 skill 名,某个 agent 可以有自己的定制版本覆盖全局版本。
当前时间块值得单独说一句:buildSystem 在 soul 之后注入一段"## 当前时间"(zh-CN 时间 + 时区 + 星期)。LLM 没有实时时钟,用户说"五分钟后""明早九点"这类相对时间(包括生成 cron 表达式)必须以注入的时间为基准换算,否则会按训练数据猜时间、猜错时区。这是个很小但极其实用的机制。
(b) 历史里 role === "system" 的消息是 Consolidator 的摘要,要折叠进 system prompt。 为什么?Anthropic Messages API 不接受 system 作为消息 role(只认 user/assistant),但 Consolidator 压缩历史时会把摘要写成 role: "system" 存进 JSONL。所以这里把所有 system 摘要拼成一坨追加到 system prompt 末尾,再从消息列表滤掉。摘要不是普通历史消息,而是系统上下文的一部分。
动作 3:历史文件块的处理(省 token 的关键)
ini
const lastUserIdx = nonSystemHistory.map(m => m.role).lastIndexOf("user");
const messages = nonSystemHistory.map((m, idx) => ({
role: m.role,
content: Array.isArray(m.content)
? (idx === lastUserIdx ? resolveStoredBlocks(m.content) : compactStoredBlocks(m.content))
: m.content,
}));
用户发的图片/PDF 在历史里存的是文件路径,不是 base64。处理规则:
- 只有最后一条 user 消息 :
resolveStoredBlocks------把文件路径解析成 base64 真正喂给模型(这一轮模型要看这个文件); - 历史里所有旧消息 :
compactStoredBlocks------文件路径转成文本占位(图片用[图片: xxx.png],PDF/文档用[文件: xxx.pdf],两种占位文案不同)------之前那轮模型已分析过,结论在当时的 assistant 回复里。
为什么这么设计:重发历史里的图片 base64 会浪费大量 token + 可能撞请求大小上限。模型当初已看过图、结论也在历史里,后面只需知道"这里曾有一张图"即可。顺带一提,resolveStoredBlocks 读文件失败时不会让整轮崩溃,而是降级成 [图片无法加载: ...] 文本占位继续跑。
动作 4:调 Runner 流式跑 ReAct(+ 请求过大兜底)
javascript
for await (const ev of this.runner.runStream(effectiveSystem, messages, signal, msg.session_key)) {
const done = await this.forwardRunnerEvent(ev, sink, t => { assistantText += t; }, () => assistantText);
if (done) { totalInputTokens += done.input_tokens; totalOutputTokens += done.output_tokens; }
}
ReAct 循环真正发生在 this.runner.runStream(...) ------交给该 agent 自己的 Runner 反复"思考-工具-观察",边跑边产出流式事件(text/tool_call/tool_result/done/error)。Loop 用 forwardRunnerEvent 把每个 Runner 事件转成 OutboundEvent 并 await sink(ev) 推给 GatewayCore------这个 await 是有意为之的背压 :sink 推得多慢,循环就消费得多慢,不会在内存里缓冲事件。最终回复文本由 Loop 自己累加 text 事件拼出(assistantText),不依赖 sink。注意 runStream 还传了 msg.session_key,一路透传到工具执行,让工具能感知自己属于哪个会话。非流式的 runner.run 仍保留,但只有子 agent 路径在用。
这里有个兜底,且流式/非流式行为不同 。如果跑的时候报"请求过大"(413 或响应体里带 too large)------无 sink(非流式调用方)时保留原语义,Loop 用 stripFileBlocks 把所有 消息里的图片/PDF 块全替换成 [图片已移除]/[PDF 文档已移除] 文本再重跑一次;有 sink(流式)时不重试------已推出去的 token 无法撤回,重跑会让 SSE 客户端看到重复 token,所以直接抛出,由外层 catch 推 error 事件。这是动作 3 之外的二级降级------动作 3 是"只最后一条带文件",这里是"连最后一条的文件也去掉",确保至少能跑通。
动作 5:触发记忆压缩(L2 Consolidator)
kotlin
if (this.consolidator?.shouldConsolidate(totalInputTokens)) {
await this.consolidator.consolidate(msg.session_key, history);
}
Runner 流式跑完,Loop 把各 done 事件携带的 token 累加成 totalInputTokens,超过阈值就触发 Consolidator:保留最近若干条,其余让 LLM 摘要,重写 JSONL。关键 per-agent 点:这个 consolidator 是该 agent 自己的实例,阈值可以 per-agent 覆盖 (def.memory?.consolidate_at_tokens ?? 全局默认 80000)------一个吞吐大、上下文长的 agent 可以设更高阈值,一个轻量 agent 用全局默认。这一步在 process 内部同步 await,因为压缩完历史才能给下一轮用。
动作 6:记 trace + 日志 + 触发 onTurnEnd
记完成 trace、打日志(含 token 消耗、耗时、回复前 500 字)。onTurnEnd 传的是"历史快照 + 手动补上这条 assistant 回复"------因为此时 GatewayCore 还没把回复 append 进历史,所以手动 push 一份给钩子用(分析/统计场景需要看到完整轮次)。
动作 7:返回 + 错误兜底
javascript
return assistantText;
} catch (err) {
await this.hooks.onError?.(err);
if (signal.aborted) {
await sink?.({ type: "error", message: "已停止", partial_text: assistantText });
return "已停止";
}
logger.error("agent turn failed", ...);
// 配置类错误对用户可操作,原样或翻译后透出;其余保持通用文案防内部细节泄漏
const errText = err instanceof Error ? err.message : String(err);
let userFacing = "处理时发生错误,请稍后重试";
if (errText.startsWith("LLM API Key 未配置")) userFacing = errText;
else if (/\b401\b|\b403\b|无效的授权|authentication/i.test(errText)) {
userFacing = "LLM 认证失败(key 无效或过期):请在配置页检查 API Key 后重试";
}
await sink?.({ type: "error", message: userFacing, partial_text: assistantText });
return userFacing;
}
正常返回 assistantText(流式累加出的最终回复文本)。错误兜底分三档:
- 用户主动
/stop(signal.aborted)返回"已停止"; - 配置类错误透出 ------API Key 缺失/认证失败这类错误用户自己能修,原样或翻译后透出(Provider 层的
guardApiKey会抛"LLM API Key 未配置:请在配置页填写 API Key",Loop 识别后直接透传;401/403 等认证错误翻译成"请检查 API Key"的提示),不让用户面对一句无法行动的通用报错; - 其余错误(LLM 挂了、工具崩了)统一返回"处理时发生错误,请稍后重试",防内部细节泄漏。
所有错误路径都会先 await sink 推一个 error 事件(带上已产出的 partial_text)再 return------SSE 流必须收到终止事件,客户端才知道流结束了,还能看到已生成的半截内容。
注意错误也返回字符串,不抛。 因为 GatewayCore 是 const response = await this.processor(...),如果抛异常,虽然 finally 能释放锁,但"用户消息进历史了却没对应的 assistant 回复"会让历史不一致。所以 Loop 把错误吃掉返回兜底文案,保证历史总是 user/assistant 成对。
整张图看 process 的 7 个动作:
scss
GatewayCore 调 entry.loop.process(msg, history, signal, sink)
│
▼
① 开 trace + onTurnStart 钩子
│
▼
② contextBuilder.buildSystem(channel) ← 该 agent 的 SOUL + 当前时间 + 已启用 skill 索引 + 渠道 policy
+ 把历史里的 system 摘要折叠进 system prompt
+ 滤掉 system 角色消息
│
▼
③ 处理历史文件块:
最后一条 user -> 文件解析成 base64(模型这轮要看)
旧消息 -> 文件路径转文本占位(省 token)
│
▼
④ for await runner.runStream(...) 流式跑 ReAct,事件逐个 await sink 推出 ← ★ 真正的推理在这里
(请求过大兜底:非流式全去文件块重跑;流式不重试,直接抛给外层 catch 推 error 事件)
│
▼
⑤ 累加的 totalInputTokens 超阈值? -> 该 agent 的 Consolidator 压缩历史(L2)
│
▼
⑥ 记 trace + 日志 + onTurnEnd 钩子
│
▼
⑦ 返回 assistantText(流式累加的回复)
(异常:先 await sink 推 error 事件带 partial_text,再按错误分档返回兜底文案,不抛)
五、AgentRunner:真正的 ReAct 循环
Loop 把活交给 Runner,Runner 才是反复"思考-行动-观察"的地方。
1. 准备工作
ini
const tools = this.toolRegistry.definitions(); // 该 agent 的工具 schema 给 LLM
const history: LLMMessage[] = [...messages]; // 复制一份历史,循环里往里追加
let totalInputTokens = 0;
let totalOutputTokens = 0;
tools 从该 agent 自己的 ToolRegistry 拿(已按白名单过滤),history 复制一份,后面每轮的 assistant 回复、tool_result 都往这个数组追加------这就是 ReAct 循环的"记忆累积"。
2. 循环主体(流式版)
ini
for (let i = 0; i < this.maxIterations; i++) { // Runner 的 maxIterations,默认 30(回退全局 config.llm.max_iterations)
let response: LLMResponse | null = null;
for await (const ev of this.provider.streamComplete(system, history, tools, signal)) {
if (ev.type === "text_delta") yield { type: "text", text: ev.text }; // 逐 token 往外推
else if (ev.type === "tool_use_start") yield { type: "tool_call", id: ev.id, name: ev.name };
else if (ev.type === "message_complete") response = ev.response; // 本轮完整响应
}
if (!response) break;
totalInputTokens += response.input_tokens;
totalOutputTokens += response.output_tokens;
...
}
provider 接口是双方法:主路径用 streamComplete(...)(AsyncGenerator,逐条产出 text_delta/tool_use_start/tool_input_delta/message_complete 四种 StreamEvent),非流式的 complete(...) 保留给子 agent 路径用;两种协议的 Provider 都实现了这两个方法。Runner 把 provider 的细粒度事件边转发(yield)边攒,收到 message_complete 就拿到和非流式等价的完整 LLMResponse------从这往后的判断逻辑(stop_reason、组装 assistant 消息、并行工具)与非流式 run 完全一致。
每一轮把 system + 累积的 history + tools 全发给 LLM,累加 token。关键:每一轮都把完整的累积 history 发过去。LLM 是无状态的,它不记得上一轮说了啥,全靠我们把历史原样回放。所以 history 数组越来越长,token 越烧越多------这就是为什么 Loop 外面要有 Consolidator 压缩历史。
3. 终止条件------循环什么时候结束
lua
if (response.stop_reason !== "tool_use" || response.tool_calls.length === 0) {
yield { type: "done", input_tokens: totalInputTokens, output_tokens: totalOutputTokens };
return;
}
这是 ReAct 循环的唯一正常出口 :LLM 这轮返回的 stop_reason 不是 tool_use(或没有 tool_calls),说明它不想再调工具,直接输出最终文本------循环结束,yield 一个 done 事件(携带累积 token 总数)后返回,Loop 那边收到 done 就知道这轮推理完了。
stop_reason 两种典型值:"tool_use"(想调工具,继续循环)、其他如 "end_turn"(答完了,返回结束)。这就是 ReAct 的核心机制:LLM 自己决定"继续调工具"还是"输出最终答案",我们只是执行器,它要工具就执行,不要了就结束。
4. 继续循环------把 assistant 回复追加进历史
php
const assistantContent: Array<TextBlock | ToolUseBlock> = [];
if (response.content) {
assistantContent.push({ type: "text", text: response.content }); // LLM 的思考文本
}
for (const call of response.tool_calls) {
assistantContent.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
}
history.push({ role: "assistant", content: assistantContent });
LLM 一轮输出可能含两部分:text(思考过程,可选,如"我需要查一下业务系统")+ tool_use(要调的工具 + 参数,可能多个)。组装成一条 assistant 消息 push 进 history。这条消息同时含 text 和 tool_use 块------Anthropic API 要求 assistant 消息把思考和工具调用放一起,不能拆。
5. 执行工具(并行)
javascript
const results = await Promise.all(
response.tool_calls.map(async call => {
const result = await this.toolRegistry.execute(call.name, call.input, signal, this.ctx, sessionKey);
return {
type: "tool_result" as const,
tool_use_id: call.id, // ★ 靠 id 关联回对应的 tool_use
content: result.content,
is_error: result.is_error,
};
}),
);
history.push({ role: "user", content: results });
两个要点:
(a) 并行执行。 Promise.all------如果 LLM 一次性要调 3 个工具(比如同时查 3 张工单),这三个并发跑,不串行等。
(b) tool_result 用 role: "user" 追加 ,靠 tool_use_id 关联回上一条 assistant 消息里的 tool_use 块------LLM 靠 id 知道"这个结果对应我刚才哪个工具调用"。这是 Anthropic API 的约定。
这里还有一处 per-agent 化的细节:toolRegistry.execute 传了 this.ctx(该 agent 的运行环境:路径、护栏覆盖)和 sessionKey。工具执行时的文件访问权限、cwd 校验等,走的是这个 agent 自己的护栏配置(缺省 fallback 全局),不是全局一刀切。
流式版还多一步:工具结果 push 进 history 之前,先逐个 yield tool_result 事件出去------sink 那头的渠道能实时看到"正在调用 X / X 执行完了"的进度(企微/飞书有 show_tool_progress 开关)。
6. 超过最大迭代------兜底报错(流式/非流式分工)
javascript
// 非流式 run():抛错
throw new Error(`agent exceeded max_iterations (${this.maxIterations})`);
// 流式 runStream():不抛,yield error 事件
yield { type: "error", message: `agent exceeded max_iterations (${this.maxIterations})`, ... };
循环跑满 max_iterations(默认 30)LLM 还在调工具不收尾,直接兜底,防止 LLM 陷入死循环(如工具一直返回错误、LLM 一直重试)耗尽资源。两种模式分工不同:非流式 run() 抛 Error,被 Loop 的 catch 兜住;流式 runStream 不抛 ,而是 yield 一个 error 事件,Loop 的 forwardRunnerEvent 把它转成 OutboundEvent error 推给 sink,随后循环正常收尾、已累积的部分文本作为回复返回------流式场景下抛异常会让已推出去的 token 和最终状态对不上。
ReAct 单轮流程图:
scss
进入第 i 轮循环
│
▼
provider.streamComplete(system, history, tools) ← 把累积历史全发给 LLM(流式,逐 token 产出)
│
▼
stop_reason 是 tool_use 且有 tool_calls?
│
├─ 否 ──▶ 返回最终回复 + 累积 token ★ 正常出口
│
└─ 是
│
▼
组装 assistant 消息(text 思考 + tool_use 调用)push 进 history
│
▼
Promise.all 并行执行所有 tool_calls(带 agent 的 ctx + session_key)
│
▼
把 tool_result(带 tool_use_id 关联)作为 user 消息 push 进 history
│
▼
进入第 i+1 轮循环(LLM 拿着工具结果再思考)
用一个例子串一遍------用户问"查一下工单 1024 的处理状态":
ini
第 0 轮:
history = [user: "查一下工单 1024 的处理状态"]
LLM 返回: text="好的,我来查" + tool_use: query_ticket({id: 1024})
stop_reason = tool_use -> 继续
history 追加: assistant[text + tool_use]
执行 query_ticket -> 返回 {status: "已办结", handler: "客服A"}
history 追加: user[tool_result]
第 1 轮:
history = [user, assistant(text+tool_use), user(tool_result)]
LLM 返回: text="工单 1024 已由客服 A 办结"(没有 tool_use)
stop_reason = end_turn -> 返回 ★
两轮就结束。如果 LLM 觉得信息不够,可能第 1 轮还要再调工具(比如查流转记录),那就进第 2 轮,直到它不再要工具为止。
六、GatewayCore -> Loop -> Runner 三层衔接
三层各自的边界
| 层 | 管什么 | 不管什么 |
|---|---|---|
| GatewayCore(调度层) | 找 agent、路由、锁、队列、模型覆盖(model_hint/provider_hint 临时构造 Loop)、流式出站 | 怎么推理、怎么调 LLM |
| AgentLoop(编排层) | system prompt 组装、历史文件块省 token、请求过大兜底、记忆压缩、Runner 事件流转成 OutboundEvent 推 sink、错误吃成兜底文案 | ReAct 循环本身 |
| AgentRunner(推理层) | ReAct 循环、调 LLM、执行工具、累积 token | 历史怎么来、要不要压缩 |
一句话:GatewayCore 找到对应 agent 并准备消息和历史,该 agent 的 Loop 打磨历史再决定要不要压缩,该 agent 的 Runner 反复问 LLM 调工具直到出答案。
历史的两次写入------谁写、写哪
| 时机 | 谁写 | 写什么 | 落哪 |
|---|---|---|---|
| process 调用前 | GatewayCore | user 消息 | JSONL(持久化) |
| process 内部 | Loop/Runner | assistant 思考、tool_use、tool_result | 只在内存数组 history 里,不落 JSONL |
| process 返回后 | GatewayCore | assistant 最终回复 | JSONL(持久化) |
ReAct 循环过程中的中间步骤(思考、工具调用、工具结果)不进 JSONL 持久化 ,只存在于 Runner 那次 runStream() 的内存 history 数组里,跑完就丢。JSONL 里只存最终两条:user 消息 + assistant 最终回复。下次会话恢复时,历史是"干净的对话",不是一坨 tool_use/tool_result。
为什么这么设计:①持久化的是"对话",不是"推理过程"------用户关心问什么答什么,中间调了啥工具是实现细节;②省存储------一次推理可能调十几次工具,全存下来 JSONL 膨胀很快;③下次恢复不用回放工具结果------工具结果是当时快照,存下来下次也没意义。
另外,GatewayCore 写 user 消息前会加三行元信息前缀 [channel: xxx]/[user_id: xxx]/[chat_id: xxx] 再进历史------让 LLM 知道"这句话是谁、在哪个渠道、哪个会话里说的",群聊多用户场景下尤其重要。
signal(AbortSignal)怎么穿透三层
/stop 命令要能取消正在进行的 LLM 调用,靠 signal 一路传:
scss
GatewayCore:const ac = new AbortController(); 存进 abortControllers map
processor(msg, history, ac.signal, sink) ← 传 signal + sink
│
Loop.process(msg, history, signal, sink)
└─ runner.runStream(system, messages, signal, session_key) ← 透传
└─ provider.streamComplete(system, history, tools, signal) ← 透传给 LLM
用户发 /stop(priority 命令,绕过锁)时,GatewayCore 调 ac.abort()。signal 一路传到 provider.streamComplete,LLM 调用被中断抛 AbortError。这个错误从 provider 抛出 → Runner 的 for 循环没 catch 继续往上抛 → 到 Loop 的 try/catch 被兜住 → Loop 检查 signal.aborted,先 await sink 推一个带 partial_text(已产出的部分回复)的 error 事件,再 return "已停止"。所以 /stop 之后用户看到"已停止",而不是错误;SSE 客户端也能正常收到流的终止事件。
跟 Mid-turn Queue 怎么配合(同一会话串行)
css
用户连发 A、B、C 三条消息(同一会话)
A 拿到锁 ──▶ GatewayCore.handleAgent(A)
└─ entry.loop.process(A)
└─ Runner 跑 ReAct(可能要好几十秒,调好几次 LLM)
│
这期间 B、C 进来 ◀─────── 发现会话有锁,进 Mid-turn Queue 暂存
│
A 跑完,Loop 返回回复
GatewayCore:回复进历史 + finally 释放锁 + 排空 Mid-turn Queue
│
▼
B、C 被取出,各自递归 dispatch,重新拿锁跑 Loop/Runner
锁保证同一会话的 Runner 不会有两个实例同时跑。如果没这个锁,B、C 会各自起一个 Runner,三个 Runner 共享同一份 JSONL 历史并发读写------历史会乱、回复会串、token 三倍烧。
需要说明的是,锁是 per-session 的,session_key 是 agent:channel:chat_id 格式 (比如 reviewer:wecom:zhangsan)。所以不同 agent 服务同一个用户时,天然是不同的 session_key、不同的锁、不同的历史------一个 agent 在跑几十秒的 ReAct,不会挡住另一个 agent 响应同一个用户。串行约束只在"同一 agent + 同一会话"内成立。
三层错误处理的分工
| 层 | 错误来源 | 怎么处理 |
|---|---|---|
| Runner | 超过 max_iterations | 分流式:非流式 run() 抛错往上抛;流式 runStream 不抛,yield error 事件由 Loop 转发 sink |
| Runner | 工具执行失败 | 不抛------tool_result 带 is_error: true 返给 LLM,让 LLM 决定怎么办 |
| Loop | 请求过大(413) | 非流式兜底:去文件块重跑一次;流式不重试(已推 token 无法撤回),直接抛给外层 catch |
| Loop | signal.aborted | 先 sink 推 error 事件(带 partial_text),再返回"已停止" |
| Loop | 配置类错误(key 缺失/认证失败) | 透出可操作的提示(如"请检查 API Key") |
| Loop | 其他所有错误 | 先 sink 推 error 事件,再返回"处理时发生错误,请稍后重试",不抛 |
| GatewayCore | Loop 返回的兜底文案 | 当普通回复处理:进历史(出站已由 sink 的 done/error 事件推过) |
特别看 Runner 对工具错误的处理 :工具挂了不抛异常 ,而是把错误包成 tool_result 的 is_error: true 喂回给 LLM。这是 ReAct 的精髓------LLM 拿到"工具失败了"这个观察,自己决定下一步(换参数重试、换别的工具、或直接告诉用户"查询失败")。这比直接抛异常中断循环优雅得多,让 LLM 有自我修复的机会。
七、设计局限与演进记录
如实记录几处已知不彻底的地方------前两处后来已修复,正好当作演进素材;后两处还留着。
1. 提示层/执行层的文件访问不一致(已修复)。 原来 ContextBuilder.buildSystem 读 system_allowed_paths 用的是全局 getConfig().guardrails,造成"提示层用全局、执行层用 per-agent"的不一致------LLM 可能被提示去访问一个执行层其实不允许的目录,或反之。修复方式:ContextBuilder 构造函数收第 4 参 ctx,buildSystem 改用 getEffectiveFileAccessLevel(this.ctx) / getEffectiveSystemAllowedPaths(this.ctx),提示层和执行层同源,都吃 per-agent 的有效配置。改动小、收益明确,属于"正确性问题优先修"的典型。
2. Provider 只有 model 级 per-agent(已修复)。 原来agent 能选模型,但 api_key/base_url 是全局的------多个 agent 接不了不同供应商账号。后来 LLM 配置重构为多 provider 槽位 :全局 llm.providers.<name> 各带 api_key/base_url/model/api 协议,agent.json 用 provider 字段选槽位,resolveModel(def.model, def.provider) 解析,配错抛错不隐式降级。详细设计见 09-Provider层。
3. 主 AgentLoop 的 per-agent max_iterations 覆盖实际未生效(仍在)。 AgentDefinition.max_iterations 字段在设计上是 per-agent 可覆盖的,registry.ts 装配时也算出了 maxIterations = def.max_iterations ?? 全局,但主 AgentLoop 的构造函数不接受 maxIterations 参数 ,内部 new AgentRunner(provider, toolRegistry, undefined, ctx) 第三参传的是 undefined------所以主 loop 路径的 per-agent 覆盖实际未生效 ,Runner 回退到全局 config.llm.max_iterations。registry.ts 里算出的那个变量是 dead code。真正用上 per-agent max_iterations 的是子 agent 路径 :子 agent 编译时把 subDef.max_iterations 传给了 new AgentRunner。所以"max_iterations 可 per-agent 覆盖"这个说法,严格讲目前只对子 agent 成立,主 loop 是全局值。这是装配链路里"设计有、实际没有"的典型------字段加了、配置算了、参数断了,三处只要有一处断链,功能就没通。
4. getEntry 不校验 active 状态(仍在)。 AgentLoop 处理的消息如果带 agent_hint,dispatch 会走 registry.getEntry(hint) 直接取该 agent,这条路径不校验 active------draft/archived 的非活跃 agent 也能被命中。而 routeAgent 路径只在 active agent 里选。两条路径对"能不能访问未发布 agent"的语义不一致,知道 draft agent 名字的人能通过 hint 直接调用它绕过发布流程。
5. max_iterations 是粗粒度的死循环防护(仍在)。 它只能防"跑满 30 轮",但如果 LLM 在第 5 轮就陷入"调同一个工具、拿同样的错、再调"的短循环,现在没有检测重复调用的机制,会白白烧到第 30 轮才兜底报错。改进思路:在 Runner 里记录最近几轮的工具调用签名(name + input 的 hash),如果连续 N 轮调用完全相同且都 is_error,提前终止并给 LLM 一个明确的"你在重复,请换个思路或告诉用户失败"的信号------能省 token、加快失败反馈。
小结
GatewayCore 找到消息对应的 agent、准备好消息和历史,交给该 agent 自己的 Loop;Loop 把历史打磨好(system prompt 组装 + 文件块省 token)交给该 agent 自己的 Runner,Runner 以 runStream 流式地反复问 LLM 调工具直到 LLM 不再要工具,text/tool_call/done 事件逐条 await sink 推出站(背压),回复同时落历史;中间 ReAct 的工具调用全在内存、只有最终对话落 JSONL;同一 agent 同一会话靠锁保证 Runner 串行,工具失败不抛而喂回 LLM 让它自我修复。这套 Loop/Runner/ContextBuilder/Consolidator 不是全局单例,而是每个 agent 在加载期各构建一套------把配置复杂度在加载期消化,运行期保持干净。