1529 字
约 5 分钟
5
ReAct运行时
2026-10-13
无标签
系列:ToyAgent 智能体知识库 17/26
ReAct 运行时:turn/step 两级循环 + 上下文裁剪 + TurnEndReason
一句话核心:从"一个 while 的循环"到"运行时",差三样东西——两级循环驱动、上下文预算管理、统一的结束原因(sealed 类型收敛所有退出路径)。
为什么需要
最朴素的 ReAct 循环只有一个 while,它回答不了三个工程问题:
- 循环怎么组织? 一次用户消息是一次"回合(turn)",回合内部工具执行完不该结束回合,而应该"续步"继续循环——这个语义裸 while 表达不出来。
- 上下文怎么管? 历史跨对话累积,聊十轮后提示词里全是陈年旧账,token 烧钱且稀释注意力。
- 循环怎么停? "停了"有很多种原因——正常答完、步数熔断、模型调用出错。如果每种停法都靠散落的
return null/throw,调用方没法做统一处理。
工业级的做法(参考 dsh-java 的 ReactLoopAgent,994 行)是把这三件事做成显式机制。
核心机制
机制一:turn/step 两级循环
chat() = 1 个 turn → while = step 循环 → 模型/工具续步 → TurnEndReason 收场
与朴素版本的关键区别:工具执行完不结束回合,而是续步(mid-turn continuation)——观察结果进历史,循环继续,直到出现结束原因。
private TurnEndReason runTurn(String input) {
for (long step = 1; step <= MAX_STEPS_PER_TURN; step++) {
String raw = model.chat(buildRequest()); // 系统提示词 + 裁剪后的历史
// 模型给出 final → Completed;发起工具 → 执行后续步
if (hasFinal(raw)) return new TurnEndReason.Completed("模型给出最终答案");
String obs = executeTool(raw);
history.add(Message.user("Observation: " + obs)); // 观察回填 → 续步
}
return new TurnEndReason.MaxSteps(MAX_STEPS_PER_TURN); // 保险丝
}
TurnEndReason 是 sealed 封闭类型:
sealed interface TurnEndReason permits Completed, MaxSteps, Error {}
所有"循环为什么停"被收敛成一个封闭代数类型。sealed 带来编译器穷尽检查:将来新增一种结束原因(比如 BudgetExceeded),所有 switch 处理它的地方编译器都会逼你补分支——退出路径永远不会被遗漏处理。
机制二:上下文裁剪 truncateToBudget
// token 估算:教学化近似 token ≈ 字符数 / 4
int tokens = history.stream().mapToInt(m -> m.content().length() / 4).sum();
// 超预算:从最老的消息开始丢,保底留最近 4 条
while (tokens > BUDGET && history.size() > KEEP_MIN) history.remove(0);
历史跨对话累积,多聊几轮后轨迹里会出现"上下文裁剪"事件。生产版还会叠加 LLM 摘要压缩:把被裁掉的旧对话先总结成一段摘要塞回开头,信息不直接蒸发。
机制三:步数保险丝
单回合最多 6 步(生产值可到 50)。给一个"死循环测试"输入——让模型永远发起同一个工具调用——就能亲眼看到保险丝起跳,回合以 TurnEndReason.MaxSteps 收场,而不是无限烧下去。
graph TD
T["turn 开始:一次用户消息"] --> S["step 循环 step=1..N"]
S --> M["模型调用:系统提示词 + 裁剪后历史"]
M --> D{"模型输出判定"}
D -->|给出 final| C["TurnEndReason.Completed"]
D -->|发起工具| E["执行工具"]
E --> O["Observation 回填历史"]
O --> B{"上下文超预算?"}
B -->|是| TR["truncateToBudget<br/>丢最老消息保底留 4 条"]
B -->|否| S
TR --> S
S -->|step 超过 MAX_STEPS| MS["TurnEndReason.MaxSteps"]
M -->|模型调用异常| ER["TurnEndReason.Error"]
两级循环全景:工具执行完不结束回合而是续步,历史超预算先裁剪再进模型,三种退出路径统一收敛到 sealed 的 TurnEndReason。
举例
一次完整回合的执行轨迹(含续步、裁剪与熔断):
用户:帮我把这段文档总结成三个要点,并把英文段落翻译成中文
[turn 开始] step=1 模型 → 发起工具 extract_sections(doc)
执行 → Observation 回填历史 → 续步(回合不结束)
step=2 模型 → 发起工具 translate(paragraph_3)
执行 → Observation 回填 → 续步
step=3 模型 → 给出 final:"三个要点是……"
→ TurnEndReason.Completed
─── 上下文裁剪场景 ───
用户(第 12 轮对话):还是总结一下今天聊的吧
调模型前估算:history 共 8900 字符 ≈ 2225 token > BUDGET=1000
→ 从最老的消息开始丢,保留最近 4 条 + 每轮的摘要头
→ 轨迹渲染出「上下文裁剪:移除 6 条,估算 token 2225 → 940」
─── 熔断场景 ───
用户:死循环测试
step=1 模型 → echo("loop") → 续步
step=2 模型 → echo("loop") → 续步
……
step=6 → TurnEndReason.MaxSteps(6),回合强制收场,不再调用模型
面试考点
- turn 和 step 的区别是什么? turn 是一次用户消息触发的完整回合;step 是回合内的一次"模型调用 + 工具续步"。一个 turn 包含 1..N 个 step,MAX_STEPS 限制的是 step 数。
- 为什么用 sealed interface 表达结束原因? 穷尽性检查:编译器保证新增结束原因时所有处理点都补齐分支,把"漏处理一种退出路径"这类 bug 从运行时提前到编译期。
- 朴素裁剪(丢最老消息)的问题是什么? 可能裁掉关键事实(用户名、约束条件)。生产版要叠加摘要压缩和重要度保留策略,或像画像一样把结构化事实单独持久化。
生产化路径
- token 估算从"字符数/4"换成真实 tokenizer(jtokkit / tiktoken),中文场景近似误差会大得多。
- 裁剪策略升级:摘要压缩 + 重要度打分 + 事实性内容单独入库(见记忆分层)。
- 结束原因接入可观测面板:
Completed / MaxSteps / Error的分布是调优提示词的直接依据——MaxSteps 占比高说明模型在空转。 - 阅读顺序建议:ToolDefinition(约 70 行)→ ToolRegistry → ReactLoopAgent → ToolCallExecutor(并行调度),这是从玩具到工业级的完整阶梯。
评论
0 条
还没有评论,先写一条吧。