手写 ReAct(Reasoning + Acting)引擎:思考-行动-观察状态机
本节目标
- 深入理解经典论文《ReAct: Synergizing Reasoning and Acting in Language Models》的核心思想
- 掌握为什么让模型在调用工具前先显式输出
Thought(思考过程)能大幅降低胡乱调工具的概率 - 手写带重复动作死循环检测(Loop Guard)与轨迹可观测事件流的完整 ReAct 引擎
为什么「先出声思考,再动手调工具」如此重要?
由于自回归语言模型(Autoregressive LLM)是按 Token 从左到右逐个采样预测的,如果你要求模型直接输出工具调用的 JSON 参数,模型没有「打草稿」的计算空间(Scratchpad),很容易在面对多步条件判断时选错工具或填错参数。
ReAct(Reason + Act) 范式的精髓在于:强制模型在每一步行动(Action)之前,先根据上一轮的环境观察(Observation)生成一段显式的推理链(Thought):
- Thought(思考):盘点目前已拿到了哪些信息、还缺什么信息、为什么接下来要调用某个工具。
- Action(行动):输出具体的工具名称与结构化参数。
- Observation(观察):宿主执行工具后返回的真实结果,触发下一轮
Thought。
生产环境中 ReAct 最常见的病态:原地打转(Looping)
在实际线上运行中,ReAct Agent 最常遇到的故障是:模型连续两次用完全相同的参数调用同一个工具(例如搜索某个不存在的文件名,返回空列表后,下一轮它不甘心又用一模一样的参数再搜一次)。如果不在引擎层拦截,它会一直重复到 maxSteps 耗尽。
手写带死循环自愈拦截的 ReAct 状态机引擎
下面的代码实现了完整的 ReAct 循环,并内置了基于签名哈希(toolName + canonicalArgs)的重复动作检测器:一旦发现模型试图重复执行相同调用,立即在本地注入一条警告提示强制其换思路:
export class ReActEngine {
constructor({ llm, registry, maxSteps = 10, onStepEvent }) {
this.llm = llm
this.registry = registry
this.maxSteps = maxSteps
this.onStepEvent = onStepEvent
}
#buildActionSignature(name, rawArgs) {
try {
const obj = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs
const sortedKeys = Object.keys(obj).sort()
const normalized = sortedKeys.map((k) => `${k}:${JSON.stringify(obj[k])}`).join('|')
return `${name}(${normalized})`
} catch {
return `${name}(${String(rawArgs).trim()})`
}
}
async solve(question) {
const systemPrompt = [
'你是一个严格遵循 ReAct(思考-行动-观察)范式的智能体。',
'每次调用工具前,请先在 content 中简要写出你的【思考 Thought】:目前已知什么、下一步为什么要调该工具。',
'如果某个工具返回空结果或报错,严禁使用完全相同的参数重复调用,请更换查询词或换用其他工具。'
].join('\n')
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question }
]
// 记录已执行过的 (工具名 + 归一化参数) 签名及其返回结果
const executedSignatures = new Map()
const trajectory = []
for (let step = 1; step <= this.maxSteps; step++) {
const response = await this.llm.chat({
messages,
tools: this.registry.toOpenAiTools()
})
const msg = response.message
messages.push(msg)
const thought = (msg.content || '').trim()
if (thought) {
this.onStepEvent?.({ type: 'thought', step, content: thought })
}
// 若无工具调用,说明推理收敛,直接输出最终答案
if (!msg.tool_calls || msg.tool_calls.length === 0) {
this.onStepEvent?.({ type: 'final_answer', step, content: thought })
return { ok: true, steps: step, answer: thought, trajectory }
}
const toolMessages = []
for (const tc of msg.tool_calls) {
const sig = this.#buildActionSignature(tc.function.name, tc.function.arguments)
// 死循环拦截:若之前已用完全相同的参数调用过该工具,直接拦截并注入换思路指令
if (executedSignatures.has(sig)) {
const prevResult = executedSignatures.get(sig)
const warning = JSON.stringify({
ok: false,
loopDetected: true,
warning: `禁止重复调用!你在第 ${prevResult.step} 步已执行过完全相同的调用 ${sig},当时结果为:${prevResult.summary}。请立即更换参数或基于现有信息直接回答。`
})
this.onStepEvent?.({ type: 'loop_blocked', step, signature: sig })
toolMessages.push({
role: 'tool',
tool_call_id: tc.id,
name: tc.function.name,
content: warning
})
continue
}
// 正常执行工具并记录签名
this.onStepEvent?.({ type: 'action', step, tool: tc.function.name, args: tc.function.arguments })
const toolMsg = await this.registry.invokeOne(tc)
executedSignatures.set(sig, {
step,
summary: toolMsg.content.slice(0, 160)
})
this.onStepEvent?.({ type: 'observation', step, tool: tc.function.name, output: toolMsg.content })
toolMessages.push(toolMsg)
}
messages.push(...toolMessages)
}
return {
ok: false,
steps: this.maxSteps,
answer: '达到最大推理步数上限,任务未能在预算内收敛。',
trajectory
}
}
}课后练习
练习 1:在 #buildActionSignature(name, rawArgs) 方法中,为什么要先将参数 JSON 解析后再按属性名 Object.keys(obj).sort() 排序生成签名,而不是直接拼接原始 rawArgs 字符串?
答案:JSON 对象的键顺序是不敏感的。模型第一次可能输出 {"a":1,"b":2},第二次输出 {"b":2,"a":1},两者在语义上完全等价;只有先对键名做字典序归一化(Canonicalization),才能 100% 准确识别出等价的重复调用。
练习 2:当检测到重复动作(executedSignatures.has(sig))时,为什么我们不是直接抛异常终止整个 Agent,而是构造一条带 loopDetected: true 和上一次结果摘要的 role: 'tool' 消息返还给模型?
答案:模型重复调用往往是因为上下文较长导致它「忘了」几步前已经查过该参数;在 tool 响应里明确提醒它「你在第 N 步已查过且结果是什么」,模型在下一步就能立即跳出死胡同,改用新参数或直接汇总答案。