ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

无Tool Calling的ReAct Agent:纯文本结构化输出与自写解析器实战

无Tool Calling的ReAct Agent:纯文本结构化输出与自写解析器实战 1. 为什么我要绕开 Tool Calling 做 Agent1.1 一个被过度神化的机制过去一年几乎所有人聊 Agent 都绕不开 Tool Calling。模型厂商把它包装成“智能体调用外部能力的标准入口”框架层把它当成一等公民教程里清一色地教你注册函数、写 schema、等模型返回tool_calls字段。我一开始也是这么干的直到我在几个真实项目里被它反复教育。Tool Calling 的本质是什么是模型在生成 token 的过程中被训练成在特定位置输出一段符合 JSON Schema 的结构化文本然后由推理服务端解析出来交给你本地代码执行。听起来很优雅但问题恰恰出在“被训练成”这四个字上。它不是一个协议而是一种行为倾向。模型可以选择调用也可以选择不调用可以调对也可以调错可以在你完全没预期的时候突然给你塞一个空参数的调用。我踩过最典型的一个坑一个需要连续三步查询才能回答的问题模型第一步调用了工具拿到结果后第二步直接开始编答案完全忘了还有后续工具可用。你没法在 prompt 里“强制”它继续调用因为 Tool Calling 的触发权在模型手里不在你手里。1.2 无 Tool Calling 到底意味着什么所谓“无 Tool Calling 的结构化通用 Agent”说白了就是我不依赖模型原生的工具调用能力而是用纯文本约定 解析器自己实现一套“模型说人话我来翻译成动作”的机制。模型输出的永远是普通文本我在文本里约定好格式比如让它输出一段带标记的结构化内容我用正则或者轻量解析器把它抠出来判断这是“思考”还是“动作”然后决定下一步。整个过程对模型来说就是普通的文本生成没有任何特殊 token、没有任何服务端魔法。这么做的好处非常直接可控性拉满。格式是我定的解析是我写的模型不按格式来我立刻能发现并纠正而不是等一个tool_calls字段莫名其妙为空。模型无关。任何能稳定输出文本的模型都能用不管是本地小模型还是 API 大模型不依赖厂商是否支持 function calling。调试透明。整个推理链路就是一段段文本出问题直接看原文不用去猜服务端怎么解析的。成本可控。Tool Calling 往往伴随额外的 token 开销schema 描述、特殊标记纯文本约定可以把这部分压到最低。代价也有你需要自己写解析器需要自己设计格式需要处理模型“不听话”的情况。但这恰恰是我想要的控制感。1.3 这套方案适合谁如果你只是做个 demo调个天气查个汇率Tool Calling 五分钟搞定没必要折腾。但如果你遇到下面这些情况这套思路值得认真看你需要多步、有条件分支的复杂流程模型经常中途“忘记”继续调用工具。你用的模型不支持或支持得很差的 Tool Calling。你需要对 Agent 的每一步做严格审计和干预不能接受黑盒。你想把 Agent 逻辑跨模型迁移不想被某家厂商绑死。我后面所有的内容都围绕一个用 Python 从零搭起来的通用 Agent 展开核心就是 ReAct 思路 纯文本结构化输出 自写解析器。没有框架没有魔法全是能看懂能改的代码。2. 整体设计用 ReAct 思路搭骨架2.1 ReAct 到底在解决什么问题ReAct 这个词被用烂了但它的核心其实特别朴素让模型在“思考”和“行动”之间交替而不是一口气把答案吐完。传统 prompt 是“问题进答案出”。ReAct 是“问题进思考出行动出观察进再思考出……直到得出答案”。这个循环的价值在于模型每走一步都能看到上一步行动的真实结果从而修正后续推理。它把一次性的“闭卷考试”变成了多轮的“开卷作答”。我选择 ReAct 作为骨架不是因为它时髦而是因为它天然适配“无 Tool Calling”的实现方式。ReAct 的每一步本来就是文本Thought 是一段话Action 是一个动作名加参数Observation 是执行结果。这些全是纯文本我用标记把它们分隔开解析器逐个提取就行完全不需要模型原生支持任何特殊格式。2.2 我的格式约定长什么样格式设计是这套方案的地基我改过好几版最终稳定下来的约定是这样的。模型每次输出必须包含且仅包含以下结构之一思考加行动Thought: 我需要先查一下这个城市的天气。 Action: get_weather Action Input: {city: 杭州}或者直接给最终答案Thought: 我已经拿到了足够的信息。 Final Answer: 杭州今天晴气温 18 到 26 度。关键点在于Thought:、Action:、Action Input:、Final Answer:这几个前缀是硬约定解析器就靠它们定位。Action Input我强制要求是单行 JSON这样解析最稳不用处理多行嵌套的边界问题。为什么不用 XML 标签或者更花哨的格式因为我试过。XML 在模型输出里容易被转义、被截断而且 token 开销更大。纯前缀加单行 JSON是解析鲁棒性和 token 效率之间我找到的最优解。2.3 循环控制与终止条件Agent 的主循环逻辑其实就几行伪代码while step max_steps: output model.generate(prompt) parsed parse(output) if parsed.type final: return parsed.answer elif parsed.type action: observation execute(parsed.action, parsed.input) prompt output f\nObservation: {observation}\n else: prompt 格式错误请严格按照约定重新输出。\n这里有几个我反复调过的细节。max_steps一定要设我一般设 8 到 10防止模型陷入死循环。解析失败时不要直接抛异常终止而是把错误信息塞回 prompt 让它重试通常模型第二次就能改对。Observation 一定要截断工具返回的内容可能很长全塞回去会迅速撑爆上下文我一般限制在 500 到 1000 字符。提示循环里每一步都要记录完整的 prompt 和输出这是后面排查问题的唯一依据。我习惯把每一步存成一个 JSON 行出问题时直接回放。3. 核心细节解析器与 Prompt 设计3.1 解析器怎么写才稳解析器是整个方案的心脏它决定了模型输出能不能被正确理解。我的解析器分三层处理第一层按行扫描找前缀。遍历输出的每一行看它是否以Thought:、Action:、Action Input:、Final Answer:开头。这里要注意模型有时候会在前缀前后加空格或者 markdown 符号所以匹配前先 strip 并去掉可能的**之类装饰。第二层状态机组装。找到Action:后期待下一行是Action Input:。如果顺序不对或者Action后面直接跟了Final Answer判定为格式错误。第三层JSON 解析兜底。Action Input的内容用json.loads解析失败的话尝试用正则提取最外层花括号再解析还失败就返回格式错误让模型重试。import json import re def parse_output(text): lines [l.strip().lstrip(*).strip() for l in text.split(\n)] result {thought: None, action: None, action_input: None, final: None} for i, line in enumerate(lines): if line.startswith(Thought:): result[thought] line[len(Thought:):].strip() elif line.startswith(Final Answer:): result[final] line[len(Final Answer:):].strip() return {type: final, answer: result[final]} elif line.startswith(Action:): result[action] line[len(Action:):].strip() if i 1 len(lines) and lines[i1].startswith(Action Input:): raw lines[i1][len(Action Input:):].strip() try: result[action_input] json.loads(raw) except json.JSONDecodeError: m re.search(r\{.*\}, raw) if m: try: result[action_input] json.loads(m.group()) except: return {type: error, msg: Action Input 不是合法 JSON} else: return {type: error, msg: Action Input 缺失或格式错误} return {type: action, action: result[action], input: result[action_input]} return {type: error, msg: 未找到 Action 或 Final Answer}这段代码我用了很久实测下来对主流模型的输出都能兜住。唯一需要额外处理的是模型偶尔把 JSON 写成单引号这种情况我在解析失败后加一步raw.replace(, )再试一次命中率能再提一截。3.2 Prompt 里必须写死的几条规则Prompt 设计直接决定模型守不守规矩。我的系统提示词里有几条是血泪教训换来的必须写死每次只能输出一个 Action 或一个 Final Answer不能同时给。模型很爱“我既想调用工具又想顺便给答案”必须明确禁止。Action Input 必须是单行合法 JSON键名用双引号。这条要反复强调否则模型会用 Python 字典语法糊弄你。不要自己编造 Observation。模型有时候会自作聪明地“预判”工具结果必须告诉它 Observation 由系统提供它只管等。格式错误时只输出修正后的内容不要解释。否则错误信息会越滚越大。我还会在 prompt 里放一两个完整的示例轨迹展示“思考-行动-观察-再思考-最终答案”的完整流程。示例比规则管用模型照着抄的准确率明显更高。3.3 工具注册表的设计工具本身用 Python 函数实现但我不会把函数直接暴露给模型而是维护一个注册表TOOLS {} def register(name, description, func): TOOLS[name] {description: description, func: func} def execute(action, action_input): if action not in TOOLS: return f错误不存在名为 {action} 的工具 try: return str(TOOLS[action][func](**action_input)) except Exception as e: return f工具执行出错{e}注册表的好处是工具的描述和实现分离。描述会被拼进 prompt 告诉模型有哪些工具可用实现只在本地执行。模型永远看不到函数源码只看到我写给它的自然语言描述。这样既安全又能通过改描述来引导模型正确使用工具。注意工具执行一定要包 try-except把异常转成字符串返回给模型而不是让程序崩溃。模型看到错误信息后往往能自己调整参数重试这比直接中断整个流程优雅得多。4. 完整实操从零跑通一个 Agent4.1 环境准备与依赖我用的是 Python 3.10依赖极少核心就一个 HTTP 客户端。如果你用 OpenAI 兼容接口装openai或者直接用requests都行。我倾向于requests因为可控不引入额外抽象。pip install requests模型接口我用的是一个本地部署的兼容服务你也可以换成任何提供文本补全的接口。关键是要能拿到纯文本输出不要用那些会自动帮你解析 tool_calls 的封装那会把我们辛苦设计的格式搞乱。4.2 定义两个示例工具为了演示我定义两个工具一个查天气一个算数学。真实项目里换成你的业务函数即可。import json def get_weather(city): fake_db {杭州: 晴18-26度, 北京: 多云12-20度} return fake_db.get(city, f暂无 {city} 的天气数据) def calculate(expression): allowed set(0123456789-*/(). ) if not set(expression) allowed: return 表达式包含非法字符 return str(eval(expression)) register(get_weather, 查询指定城市的天气参数 city 为城市名, get_weather) register(calculate, 计算一个数学表达式参数 expression 为算式字符串, calculate)calculate里我做了字符白名单校验这是必须的。直接eval用户或模型传来的字符串是灾难模型可能生成__import__(os).system(...)这种内容。白名单只放数字和四则运算符安全边界清晰。4.3 拼装系统提示词def build_system_prompt(): tool_desc \n.join( f- {name}: {info[description]} for name, info in TOOLS.items() ) return f你是一个严谨的智能助手通过思考和行动来解决问题。 可用工具 {tool_desc} 输出格式要求必须严格遵守 1. 每次输出只能是以下两种之一 Thought: 你的思考 Action: 工具名 Action Input: 单行合法JSON 或者 Thought: 你的思考 Final Answer: 最终答案 2. Action Input 必须是单行 JSON键名用双引号。 3. 不要自己编造 Observation它由系统提供。 4. 格式错误时只输出修正后的内容。 示例 Thought: 用户想知道杭州天气我需要调用天气工具。 Action: get_weather Action Input: {{city: 杭州}} 注意示例里的 JSON 花括号要转义因为用了 f-string。这个坑我第一次写的时候踩了模型收到的示例是残缺的导致它一直输出错误格式。4.4 主循环实现def run_agent(question, max_steps8): messages [ {role: system, content: build_system_prompt()}, {role: user, content: question} ] for step in range(max_steps): output call_model(messages) parsed parse_output(output) if parsed[type] final: return parsed[answer] elif parsed[type] action: obs execute(parsed[action], parsed[input]) obs obs[:800] messages.append({role: assistant, content: output}) messages.append({role: user, content: fObservation: {obs}}) else: messages.append({role: assistant, content: output}) messages.append({role: user, content: f格式错误{parsed[msg]}请重新输出。}) return 达到最大步数未能得出答案。call_model就是普通的文本补全调用把 messages 拼成 prompt 发给模型。这里我把 Observation 作为 user 消息追加而不是拼进 assistant 消息这样更符合对话结构模型对“这是外部输入”的感知更清晰。4.5 跑一个多步任务看看我拿一个需要两步的问题测试“杭州和北京哪个更热热多少度”理想轨迹是这样的第一步模型思考需要先查两个城市天气输出Action: get_weather, Action Input: {city: 杭州}。系统返回杭州天气。第二步模型输出查北京的 Action。系统返回北京天气。第三步模型看到两个结果输出Action: calculate, Action Input: {expression: 26-20}。系统返回 6。第四步模型输出 Final Answer说明杭州更热最高温差 6 度。实测下来主流模型在给了清晰示例后这个流程基本能一次跑通。偶尔会在第二步忘记继续查北京直接编答案这时候解析器发现它输出了 Final Answer 但信息不全我加了一条规则如果 Final Answer 里提到的城市数少于问题里的城市数就提示它“信息不完整请继续查询”。这条启发式规则把这类错误压下去不少。5. 常见问题与排查实录5.1 模型不按格式输出怎么办这是最高频的问题。表现是模型输出一大段自然语言没有Thought:也没有Action:。我的处理分三步先看 prompt 里的示例是不是被截断了。上下文太长时系统提示词可能被挤掉模型就失去了格式记忆。解决办法是把格式约定放在 prompt 最前面或者每轮都重新强调一次。再看是不是模型能力太弱。小模型对格式的遵循度确实差这时候要么换模型要么把示例加得更详细甚至用 few-shot 多给几个正例。最后解析失败时返回的错误信息要具体。不要只说“格式错误”要说“未找到 Action 或 Final Answer请确保输出包含 Thought 和 Action 或 Final Answer”。具体的错误提示能让模型更快纠正。5.2 Action Input 的 JSON 老是解析失败常见原因有几个我整理成表现象原因解决单引号字典模型用了 Python 语法解析前 replace 单引号为双引号多行 JSON模型换行了提示词强调单行解析时合并连续行尾随逗号模型习惯性加逗号正则去掉,}和,]中文引号模型混用了全角符号统一替换全角引号为半角缺外层花括号模型只输出了键值对解析失败时尝试补花括号这些处理我都写进了解析器的兜底逻辑实测能把解析成功率从七成提到九成五以上。5.3 工具执行结果太长撑爆上下文工具返回一大段文本直接塞回 prompt几轮下来上下文就满了。我的做法是在execute之后统一截断超过 800 字符的部分用省略号代替并在末尾加一句“结果已截断”。如果工具结果确实关键且长我会让工具自己先做摘要只返回核心信息。提示截断长度要根据你的模型上下文窗口来定。窗口小就截短点窗口大可以放宽但永远不要不截断。5.4 模型陷入死循环反复调用同一个工具表现是模型连续多步调用同一个工具、同样的参数。原因通常是它没意识到 Observation 已经给了答案或者工具返回的内容它没看懂。我的处理是在 Observation 里加一句引导比如“以上是查询结果请基于此继续推理或给出最终答案”。另外max_steps是最后一道防线到了就强制终止并返回当前最好结果。5.5 安全边界怎么守无 Tool Calling 不代表没有安全风险。模型生成的 Action 和参数完全可能越界。我的原则是永远不信任模型输出所有执行前都校验。工具名必须在注册表里不在就拒绝。参数类型和范围要校验比如城市名做白名单表达式做字符白名单。涉及文件、网络、系统的操作一律不直接暴露给模型而是包一层受限接口。这套方案的优势恰恰在于所有执行都经过我的execute函数我可以在这一层做任何拦截和审计。6. 一些实战心得这套无 Tool Calling 的 Agent 我在几个项目里跑了小半年最大的体会是把控制权握在自己手里比依赖模型的原生能力踏实得多。Tool Calling 看起来省事但一旦出问题你几乎无从下手因为解析逻辑在服务端你只能看到结果看不到过程。而纯文本约定加自写解析器每一步都是透明的出问题直接看原文改 prompt 或者改解析器立刻见效。另一个心得是格式约定要简单到极致。我一开始设计过带嵌套、带可选字段的复杂格式结果模型错误率飙升。后来砍到只剩四个前缀加单行 JSON稳定性立刻上来了。模型不是编译器别指望它精确遵循复杂语法越简单越可靠。还有一点示例的力量远大于规则。与其写十条“你必须怎样”不如给两个完整的正确轨迹让模型照着走。我在 prompt 里放的示例几乎成了模型输出的模板它连措辞都会模仿。最后这套方案的可扩展性很好。想加新工具注册一下就行想换模型改call_model就行想加审计在execute里插日志就行。没有框架的束缚每一行代码你都知道它在干什么。对于需要长期维护、需要严格可控的 Agent 项目这种“笨办法”反而是最稳的路子。
返回列表