1. 为什么我要绕开 Tool Calling 做 Agent
1.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[i+1].startswith("Action Input:"): raw = lines[i+1][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. 完整实操:从零跑通一个 Agent
4.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_steps=8): 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": f"Observation: {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 项目,这种“笨办法”反而是最稳的路子。