从零实现 ReAct 范式:基于自定义 XML 协议的 Agent 系统
2026年01月12日30 次阅读0 人喜欢
AILLMTypeScriptnodejs多智能体系统ReActAgent
从零实现 ReAct 范式:基于自定义 XML 协议的 Agent 系统
前言
ReAct(Reasoning + Acting)是一种将推理和行动相结合的 AI Agent 范式,让模型能够通过思考-行动-观察的循环来解决问题。本文将介绍如何不依赖 LangChain 等框架,仅使用 OpenAI SDK 和自定义 XML 协议,实现一个完整的 ReAct Agent 系统,文章内容来自于AI生成的吗,嘿嘿,我觉得不是。
为什么选择自定义 XML 协议?
与常见的 JSON 格式工具调用相比,XML 协议有以下优势:
- 结构化输出更稳定:XML 标签明确,模型更容易遵循格式
- 流式处理友好:可以逐步解析部分结果
- 人类可读性强:调试时更容易理解模型输出
- 灵活性高:不依赖特定框架的工具调用格式
核心设计思路
1. 协议定义
我们使用三个核心标签来构建协议:
<thought>:模型的思考过程<action>:要执行的工具调用<observation>:工具执行后的观察结果<final_answer>:最终答案
2. 执行流程
用户问题
↓
[思考] → [行动] → [观察] → [思考] → [行动] → ... → [最终答案]
这是一个循环过程,直到模型认为可以给出最终答案。
3. 关键技术点
- 非流式请求:使用完整的请求-响应模式,确保输出完整
- 自定义解析:手动解析 XML 标签,提取工具调用信息
- 工具注册系统:灵活的工具注册和执行机制
- 迭代控制:通过最大迭代次数防止无限循环
完整实现代码
工具定义系统
typescript
const tools = [
{
name: 'mkdir',
description: '创建一个目录',
parameters: [
{
name: 'path',
type: 'string',
description: '目录路径',
},
],
function: (path: string) => {
fs.mkdirSync(path)
},
},
{
name: 'write_to_file',
description: '写入一个文件',
parameters: [
{
name: 'path',
type: 'string',
description: '文件路径',
},
{
name: 'content',
type: 'string',
description: '文件内容',
},
],
function: (path: string, content: string) => {
fs.writeFileSync(path, content)
},
},
// ... 更多工具
]
Prompt 设计
typescript
const prompt = `
你需要解决一个问题。这是一个**循环执行**的过程:
1. 先用 <thought> 思考要做什么
2. 然后使用 <action> 调用一个工具
3. **等待**我返回工具的 <observation> 结果
4. 根据 observation 决定下一步:如果还需要更多信息,继续思考并调用工具;如果已经足够,给出 <final_answer>
5. **重复步骤1-4**,直到可以给出最终答案
**重要规则**:
- 你**每次只能输出一次** <action>,然后必须停止,等待 observation
- **绝对不要**在输出 <action> 后自己生成 <observation>,这会导致错误
- **绝对不要**在还没有执行任何工具的情况下就给出 <final_answer>
- 必须通过执行工具获得足够信息后,才能给出 <final_answer>
所有步骤请严格使用以下 XML 标签格式输出:
- <thought> 思考
- <action> 采取的工具操作(格式:工具名(参数1="值1", 参数2="值2"))
- <observation> 工具或环境返回的结果(**只能由我来提供,你不要生成**)
- <final_answer> 最终答案
`
核心解析逻辑
1. 转义字符处理
typescript
/**
* 处理转义字符
*/
const unescapeString = (str: string): string => {
return str
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r')
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"');
};
2. 工具调用解析
这是最关键的部分,需要解析类似 tool_name(param1="value1", param2="value2") 的格式:
typescript
/**
* 解析工具调用的参数
* 支持格式:tool_name("param1", "param2") 或 tool_name(param1="value1", param2="value2")
*/
const parseAction = (actionStr: string): { toolName: string; params: any[] } | null => {
const match = actionStr.match(/^(\w+)\((.*)\)$/);
if (!match) return null;
const toolName = match[1];
const paramsStr = match[2].trim();
// 如果参数为空
if (!paramsStr) {
return { toolName, params: [] };
}
const params: any[] = [];
// 尝试解析命名参数格式:param1="value1", param2="value2"
// 使用更复杂的正则来匹配可能包含转义字符的字符串
const namedParamRegex = /(\w+)="((?:[^"\\]|\\.)*)"/g;
let namedMatch;
let lastIndex = 0;
while ((namedMatch = namedParamRegex.exec(paramsStr)) !== null) {
// 处理转义字符
const value = unescapeString(namedMatch[2]);
params.push(value);
lastIndex = namedParamRegex.lastIndex;
}
// 如果解析了命名参数,返回结果
if (lastIndex > 0) {
return { toolName, params };
}
// 否则尝试解析位置参数格式:"param1", "param2"
const positionalParamRegex = /"((?:[^"\\]|\\.)*)"/g;
const positionalMatches: string[] = [];
let posMatch;
while ((posMatch = positionalParamRegex.exec(paramsStr)) !== null) {
positionalMatches.push(posMatch[1]);
}
if (positionalMatches.length > 0) {
params.push(...positionalMatches.map(unescapeString));
return { toolName, params };
}
// 如果都没有匹配,尝试作为单个未引号的参数
return { toolName, params: [paramsStr] };
};
主循环逻辑
typescript
const main = async () => {
const messages = [
{ role: "system", content: prompt }
] as ChatCompletionMessageParam[];
messages.push({
role: "user",
content: `<question>帮我创建一个贪食蛇的游戏,并保存到当前目录下snake文件夹下,文件名称为snake.html,拆分css和js文件,并引入到html文件中</question>`,
})
let maxIterations = 20; // 防止无限循环
let iteration = 0;
while (iteration < maxIterations) {
iteration++;
// 非流式请求,使用 stop 参数控制输出
const completion = await client.chat.completions.create({
model: process.env.OPEN_ROUTER_XIAOMI_MODEL as string,
messages: messages,
// 添加 stop sequences,让模型在输出 </action> 后停止
stop: ['</action>', '\n<observation>', '\n<final_answer>'],
});
const content = completion.choices[0].message.content;
if (!content) {
console.log('模型没有返回内容');
break;
}
// 将助手的回复添加到消息历史
messages.push({ role: "assistant", content: content });
// 检查是否有 action
if (content.includes('<action>')) {
const actionMatch = content.match(/<action>(.*?)<\/action>/s);
if (actionMatch) {
const actionStr = actionMatch[1].trim();
console.log(`执行工具调用: ${actionStr}`);
const parsed = parseAction(actionStr);
if (!parsed) {
console.error(`无法解析 action: ${actionStr}`);
messages.push({
role: "user",
content: `<observation>错误:无法解析工具调用格式。请使用格式:工具名(参数1="值1", 参数2="值2")</observation>`
});
continue;
}
const { toolName, params } = parsed;
const tool = tools.find(t => t.name === toolName);
if (!tool) {
console.error(`未找到工具: ${toolName}`);
messages.push({
role: "user",
content: `<observation>错误:未找到工具 "${toolName}"。可用工具:${tools.map(t => t.name).join(', ')}</observation>`
});
continue;
}
// 执行工具
try {
console.log(`调用工具: ${toolName}, 参数:`, params);
// 使用 Function.apply 来安全地调用工具函数
const result = (tool.function as (...args: any[]) => any).apply(null, params);
const observation = result !== undefined ? String(result) : '工具执行成功';
console.log(`工具执行结果: ${observation}`);
// 将 observation 添加到消息历史
messages.push({
role: "user",
content: `<observation>${observation}</observation>`
});
} catch (error: any) {
console.error(`工具执行错误:`, error);
messages.push({
role: "user",
content: `<observation>错误:${error.message || String(error)}</observation>`
});
}
continue; // 继续循环,等待模型的下一个响应
}
}
// 检查是否有 final_answer
if (content.includes('<final_answer>')) {
const finalAnswerMatch = content.match(/<final_answer>(.*?)<\/final_answer>/s);
if (finalAnswerMatch) {
console.log('\n=== 最终答案 ===');
console.log(finalAnswerMatch[1].trim());
console.log('================\n');
}
break;
}
// 如果既没有 action 也没有 final_answer,可能是格式问题
console.warn('警告:模型返回的内容中既没有 <action> 也没有 <final_answer>');
messages.push({
role: "user",
content: `<observation>请按照格式输出,必须包含 <thought> 和 <action>(如果还需要执行工具)或 <final_answer>(如果已经可以回答问题)。</observation>`
});
}
if (iteration >= maxIterations) {
console.error('达到最大迭代次数,停止执行');
}
}
关键技术细节
1. Stop Sequences 的使用
通过设置 stop: ['</action>', '\n<observation>', '\n<final_answer>'],我们可以精确控制模型在输出 </action> 后停止,避免模型自己生成 observation。
2. 正则表达式解析
使用正则表达式解析工具调用,支持两种格式:
- 位置参数:
tool_name("param1", "param2") - 命名参数:
tool_name(param1="value1", param2="value2")
3. 转义字符处理
正确处理字符串中的转义字符,如 \n、\" 等,确保参数解析正确。
4. 错误处理
完善的错误处理机制:
- 工具不存在
- 参数解析失败
- 工具执行异常
优势总结
- 轻量级:不依赖 LangChain 等重型框架
- 灵活性:完全控制协议格式和执行流程
- 可调试性:XML 格式易于阅读和调试
- 可扩展性:工具系统易于扩展
- 非流式:使用标准请求-响应模式,逻辑更简单
完整代码
以下是完整的实现代码:
typescript
import OpenAI from 'openai';
import { ChatCompletionMessageParam } from 'openai/resources/chat/completions';
import os from 'os'
import fs from 'fs'
import dotenv from 'dotenv'
dotenv.config({
override: true
})
const operating_system = os.platform()
const file_list = fs.readdirSync('.')
const tools = [
{
name: 'mkdir',
description: '创建一个目录',
parameters: [
{
name: 'path',
type: 'string',
description: '目录路径',
},
],
function: (path: string) => {
fs.mkdirSync(path)
},
},
{
name: 'write_to_file',
description: '写入一个文件',
parameters: [
{
name: 'path',
type: 'string',
description: '文件路径',
},
{
name: 'content',
type: 'string',
description: '文件内容',
},
],
function: (path: string, content: string) => {
fs.writeFileSync(path, content)
},
},
{
name: 'append_to_file',
description: '追加内容到文件',
parameters: [
{
name: 'path',
type: 'string',
description: '文件路径',
},
{
name: 'content',
type: 'string',
description: '文件内容',
},
],
function: (path: string, content: string) => {
fs.appendFileSync(path, content)
},
},
{
name: 'read_file',
description: '读取一个文件',
parameters: [
{
name: 'path',
type: 'string',
description: '文件路径',
},
],
function: (path: string) => {
return fs.readFileSync(path, 'utf-8')
},
},
{
name: 'delete_file',
description: '删除一个文件',
parameters: [
{
name: 'path',
type: 'string',
description: '文件路径',
},
],
function: (path: string) => {
fs.unlinkSync(path)
},
},
{
name: 'create_file',
description: '创建一个文件',
parameters: [
{
name: 'path',
type: 'string',
description: '文件路径',
},
{
name: 'content',
type: 'string',
description: '文件内容',
},
],
function: (path: string, content: string) => {
fs.writeFileSync(path, content)
},
},
{
name: 'rename_file',
description: '重命名一个文件',
parameters: [
{
name: 'old_path',
type: 'string',
description: '旧文件路径',
},
{
name: 'new_path',
type: 'string',
description: '新文件路径',
},
],
function: (old_path: string, new_path: string) => {
fs.renameSync(old_path, new_path)
},
},
]
const tool_list = `
${tools.map(tool => `- ${tool.name}(${tool.parameters.map(param => `${param.name}: ${param.type}`).join(', ')})`).join('\n')}
`
const prompt = `
你需要解决一个问题。这是一个**循环执行**的过程:
1. 先用 <thought> 思考要做什么
2. 然后使用 <action> 调用一个工具
3. **等待**我返回工具的 <observation> 结果
4. 根据 observation 决定下一步:如果还需要更多信息,继续思考并调用工具;如果已经足够,给出 <final_answer>
5. **重复步骤1-4**,直到可以给出最终答案
**重要规则**:
- 你**每次只能输出一次** <action>,然后必须停止,等待 observation
- **绝对不要**在输出 <action> 后自己生成 <observation>,这会导致错误
- **绝对不要**在还没有执行任何工具的情况下就给出 <final_answer>
- 必须通过执行工具获得足够信息后,才能给出 <final_answer>
所有步骤请严格使用以下 XML 标签格式输出:
- <thought> 思考
- <action> 采取的工具操作(格式:工具名(参数1="值1", 参数2="值2"))
- <observation> 工具或环境返回的结果(**只能由我来提供,你不要生成**)
- <final_answer> 最终答案
⸻
例子 1:
<question>埃菲尔铁塔有多高?</question>
<thought>我需要找到埃菲尔铁塔的高度。可以使用搜索工具。</thought>
<action>get_height("埃菲尔铁塔")</action>
(等待 observation...)
<observation>埃菲尔铁塔的高度约为330米(包含天线)。</observation>
<thought>搜索结果显示了高度。我已经得到答案了。</thought>
<final_answer>埃菲尔铁塔的高度约为330米。</final_answer>
⸻
例子 2:
<question>帮我找一个简单的番茄炒蛋食谱,并看看家里的冰箱里有没有西红柿。</question>
<thought>这个任务分两步。第一步,找到番茄炒蛋的食谱。第二步,检查冰箱里是否有西红柿。我先用 find_recipe 工具找食谱。</thought>
<action>find_recipe(dish="番茄炒蛋")</action>
(等待 observation...)
<observation>简单的番茄炒蛋食谱:将2个鸡蛋打散,2个番茄切块。热油,先炒鸡蛋,盛出。再热油,炒番茄至软烂,加入鸡蛋,放盐调味即可。</observation>
<thought>好的,我已经有食谱了。食谱需要西红柿。现在我需要用 check_fridge 工具看看冰箱里有没有西红柿。</thought>
<action>check_fridge(item="西红柿")</action>
(等待 observation...)
<observation>冰箱检查结果:有3个西红柿。</observation>
<thought>我找到了食谱,并且确认了冰箱里有西红柿。可以回答问题了。</thought>
<final_answer>简单的番茄炒蛋食谱是:鸡蛋打散,番茄切块。先炒鸡蛋,再炒番茄,混合后加盐调味。冰箱里有3个西红柿。</final_answer>
⸻
本次任务可用工具:
${tool_list}
⸻
环境信息:
操作系统:${operating_system}
当前目录下文件列表:${file_list}
`
const client = new OpenAI({
apiKey: process.env.OPEN_ROUTER_API_KEY,
baseURL: process.env.OPEN_ROUTER_BASE_URL,
});
/**
* 处理转义字符
*/
const unescapeString = (str: string): string => {
return str
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r')
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"');
};
/**
* 解析工具调用的参数
* 支持格式:tool_name("param1", "param2") 或 tool_name(param1="value1", param2="value2")
*/
const parseAction = (actionStr: string): { toolName: string; params: any[] } | null => {
const match = actionStr.match(/^(\w+)\((.*)\)$/);
if (!match) return null;
const toolName = match[1];
const paramsStr = match[2].trim();
// 如果参数为空
if (!paramsStr) {
return { toolName, params: [] };
}
const params: any[] = [];
// 尝试解析命名参数格式:param1="value1", param2="value2"
// 使用更复杂的正则来匹配可能包含转义字符的字符串
const namedParamRegex = /(\w+)="((?:[^"\\]|\\.)*)"/g;
let namedMatch;
let lastIndex = 0;
while ((namedMatch = namedParamRegex.exec(paramsStr)) !== null) {
// 处理转义字符
const value = unescapeString(namedMatch[2]);
params.push(value);
lastIndex = namedParamRegex.lastIndex;
}
// 如果解析了命名参数,返回结果
if (lastIndex > 0) {
return { toolName, params };
}
// 否则尝试解析位置参数格式:"param1", "param2"
// 使用更复杂的正则来匹配可能包含转义字符的字符串
const positionalParamRegex = /"((?:[^"\\]|\\.)*)"/g;
const positionalMatches: string[] = [];
let posMatch;
while ((posMatch = positionalParamRegex.exec(paramsStr)) !== null) {
positionalMatches.push(posMatch[1]);
}
if (positionalMatches.length > 0) {
params.push(...positionalMatches.map(unescapeString));
return { toolName, params };
}
// 如果都没有匹配,尝试作为单个未引号的参数
return { toolName, params: [paramsStr] };
};
const main = async () => {
const messages = [
{ role: "system", content: prompt }
] as ChatCompletionMessageParam[];
messages.push({
role: "user",
content: `<question>帮我创建一个贪食蛇的游戏,并保存到当前目录下snake文件夹下,文件名称为snake.html,拆分css和js文件,并引入到html文件中</question>`,
})
let maxIterations = 20; // 防止无限循环
let iteration = 0;
while (iteration < maxIterations) {
iteration++;
const completion = await client.chat.completions.create({
model: process.env.OPEN_ROUTER_XIAOMI_MODEL as string,
messages: messages,
// 添加 stop sequences,让模型在输出 </action> 后停止
stop: ['</action>', '\n<observation>', '\n<final_answer>'],
});
const content = completion.choices[0].message.content;
if (!content) {
console.log('模型没有返回内容');
break;
}
console.log('\n=== 模型输出 ===');
console.log(content);
console.log('===============\n');
// 将助手的回复添加到消息历史
messages.push({ role: "assistant", content: content });
// 检查是否有 action
if (content.includes('<action>')) {
const actionMatch = content.match(/<action>(.*?)<\/action>/s);
if (actionMatch) {
const actionStr = actionMatch[1].trim();
console.log(`执行工具调用: ${actionStr}`);
const parsed = parseAction(actionStr);
if (!parsed) {
console.error(`无法解析 action: ${actionStr}`);
messages.push({
role: "user",
content: `<observation>错误:无法解析工具调用格式。请使用格式:工具名(参数1="值1", 参数2="值2")</observation>`
});
continue;
}
const { toolName, params } = parsed;
const tool = tools.find(t => t.name === toolName);
if (!tool) {
console.error(`未找到工具: ${toolName}`);
messages.push({
role: "user",
content: `<observation>错误:未找到工具 "${toolName}"。可用工具:${tools.map(t => t.name).join(', ')}</observation>`
});
continue;
}
// 执行工具
try {
console.log(`调用工具: ${toolName}, 参数:`, params);
// 使用 Function.apply 来安全地调用工具函数
const result = (tool.function as (...args: any[]) => any).apply(null, params);
const observation = result !== undefined ? String(result) : '工具执行成功';
console.log(`工具执行结果: ${observation}`);
// 将 observation 添加到消息历史
messages.push({
role: "user",
content: `<observation>${observation}</observation>`
});
} catch (error: any) {
console.error(`工具执行错误:`, error);
messages.push({
role: "user",
content: `<observation>错误:${error.message || String(error)}</observation>`
});
}
continue; // 继续循环,等待模型的下一个响应
}
}
// 检查是否有 final_answer
if (content.includes('<final_answer>')) {
const finalAnswerMatch = content.match(/<final_answer>(.*?)<\/final_answer>/s);
if (finalAnswerMatch) {
console.log('\n=== 最终答案 ===');
console.log(finalAnswerMatch[1].trim());
console.log('================\n');
}
break;
}
// 如果既没有 action 也没有 final_answer,可能是格式问题
console.warn('警告:模型返回的内容中既没有 <action> 也没有 <final_answer>');
messages.push({
role: "user",
content: `<observation>请按照格式输出,必须包含 <thought> 和 <action>(如果还需要执行工具)或 <final_answer>(如果已经可以回答问题)。</observation>`
});
}
if (iteration >= maxIterations) {
console.error('达到最大迭代次数,停止执行');
}
}
main()
总结
通过自定义 XML 协议实现 ReAct 范式,我们获得了更好的控制权和灵活性。虽然需要手动处理解析和错误处理,但这让我们对系统的每个细节都有清晰的理解。这种方法特别适合需要定制化协议和精细控制的场景。