1. 为什么我要绕开 Tool Calling 做 Agent先说结论我最近花了两周时间把一个原本依赖 Tool Calling 的 Agent 项目彻底重写成了无 Tool Calling 的结构化通用 Agent。重写之后代码量减少了大约四成调试时间缩短了一半以上而且换模型、换供应商的时候几乎不用改业务逻辑。这篇文章就把整个思路、踩过的坑、以及可以直接抄的代码结构完整讲一遍。如果你正在做 AI Agent 开发大概率遇到过这几个问题模型不支持 Tool Calling、不同厂商的 Tool Calling 格式不统一、流式输出和工具调用混在一起处理起来极其恶心、调试的时候根本不知道模型为什么没调用工具。这些问题的根源其实都在于我们把工具调用这件事过度依赖模型的原生能力了。所谓无 Tool Calling 的结构化通用 Agent核心思想很简单不依赖模型厂商提供的 function calling / tool use 接口而是用纯文本 Prompt 约定一套结构化的输出格式由我们自己的代码去解析、执行、回填结果。模型只负责思考和输出文本工具的执行、结果的拼接、循环的控制全部由 Python 代码接管。这套方案适合谁适合所有在做 Agent 但被 Tool Calling 兼容性折磨的开发者适合想深入理解 ReAct 模式本质的人也适合那些用着不支持工具调用的开源模型、却想跑完整 Agent 流程的团队。哪怕你是刚入门 Agent 开发的新手只要会写基本的 Python跟着这篇文章也能搭出一个能跑、能调试、能扩展的通用 Agent。我下面会从设计思路、核心细节、实操实现、问题排查四个维度展开中间会穿插大量我实际调试时的参数选择和判断逻辑。文章偏长但每一段都是能直接落地的干货。2. 整体设计思路与方案选型拆解2.1 为什么 Tool Calling 不是唯一解Tool Calling 的本质是模型厂商在训练阶段教会模型输出一种特定结构的 JSON用来描述我要调用哪个函数、传什么参数。听起来很美好但实际用起来问题一堆。第一是兼容性。不同厂商的格式差异很大有的用tools字段有的用functions返回结构里有的放在tool_calls有的放在function_call参数有的是字符串有的是对象。你写一套代码换个模型就得改一遍适配层。第二是可控性差。模型什么时候决定调用工具、调用几次、参数对不对你几乎无法干预。我遇到过模型明明该查天气却直接编了一个温度的情况也遇到过它把两个工具的参数搞混。排查的时候只能看日志干瞪眼。第三是流式处理的噩梦。Tool Calling 和流式输出结合的时候你得处理文本片段和工具调用片段交错到达的情况状态机写得稍微不严谨就出 bug。而无 Tool Calling 的方案把这些复杂度从模型黑盒转移到了我们自己的代码里。模型只需要输出符合约定的文本剩下的解析、校验、执行、回填全在我们掌控之中。这就是我选择这条路的核心原因。2.2 结构化输出的约定设计既然不靠原生 Tool Calling那模型怎么知道该调用工具答案是用 Prompt 约定一套结构化的文本协议。我最终采用的协议是这样的模型在需要调用工具时必须输出一个特定标记包裹的 JSON 块比如用TOOL和END作为边界。这样设计有几个好处边界清晰正则或字符串切分都能稳定提取不会和正常回答内容混淆即使模型输出里带了 Markdown 代码块也不会误伤。为什么不用纯 JSON 输出整个回复因为 Agent 的回复往往是思考 工具调用 最终答案混合的如果强制整个输出都是 JSON模型会变得很拘谨思考质量下降。用标记块的方式模型可以自由地先写一段推理再抛出工具调用最后给答案这更接近 ReAct 的自然节奏。这里有个关键取舍标记要足够独特避免和正常文本冲突。我一开始用的是[TOOL]结果模型在解释代码时经常输出方括号导致误解析。后来换成三尖括号加全大写单词的组合冲突概率几乎为零。2.3 ReAct 循环的骨架整个 Agent 的运行逻辑就是一个 ReAct 循环Thought思考→ Action行动/工具调用→ Observation观察/工具结果→ 再思考直到模型给出最终答案。无 Tool Calling 的版本里这个循环完全由 Python 控制。每一轮我们把历史消息包括之前的工具调用和结果拼成 Prompt 发给模型拿到输出后解析如果里面有工具调用标记就执行工具、把结果作为新一轮的输入追加进去如果没有就认为这是最终答案结束循环。这个骨架看起来简单但魔鬼在细节里。比如循环最多跑几轮工具执行失败了怎么办模型连续调用同一个工具怎么处理这些我后面会逐个讲。2.4 方案对比三种实现路径为了让你更清楚为什么选这条路我把常见的三种 Agent 实现方式做个对比。方案依赖兼容性可控性调试难度适用场景原生 Tool Calling模型必须支持差需适配层低中闭源大模型、快速原型无 Tool Calling 结构化纯文本能力极好高低开源模型、多模型切换纯代码编排无 LLM 决策无最好最高低流程固定的任务可以看到无 Tool Calling 的结构化方案在兼容性和可控性上都有明显优势代价是需要自己写解析和执行逻辑。但对于一个通用 Agent来说这点代价完全值得。3. 核心细节解析与实操要点3.1 Prompt 的结构化设计Prompt 是整个 Agent 的大脑设计得好不好直接决定成败。我的 Prompt 分成四个部分角色定义、工具清单、输出格式约定、约束规则。角色定义要简短明确比如你是一个能调用工具解决问题的助手。工具清单是重点每个工具要写清楚名称、用途、参数格式。这里有个技巧参数格式用示例而不是描述。与其写参数是一个字符串不如直接给一个{city: 北京}的例子模型模仿能力很强给例子比给描述有效得多。输出格式约定要反复强调。我会在 Prompt 里明确写当你需要调用工具时必须严格输出TOOL{name: 工具名, args: {...}}END不要添加任何其他字符。 实测下来这种强约束能显著降低解析失败率。约束规则包括一次只能调用一个工具、工具名必须来自清单、参数必须是合法 JSON 等。这些规则能帮模型少犯错也方便我们做校验。3.2 工具注册与描述生成工具在代码里怎么组织我用一个字典注册表每个工具是一个 Python 函数配一段描述和参数说明。TOOLS { get_weather: { func: get_weather, desc: 查询指定城市的天气, args_example: {city: 北京} }, calculator: { func: calculator, desc: 计算数学表达式, args_example: {expr: 23*4} } }然后写一个函数把这个注册表自动渲染成 Prompt 里的工具清单文本。这样做的好处是加工具只需要改注册表Prompt 自动更新不用手动维护两处。这里有个容易忽略的点工具描述要写得像给同事交代任务而不是像写 API 文档。模型对自然语言的敏感度高于结构化文档。比如查询指定城市的天气就比get_weather(city: str) - str更容易被模型正确理解。3.3 输出解析的健壮性处理解析是整套方案里最容易出 bug 的地方。模型输出千奇百怪你必须假设它永远会以最离谱的方式出错。我的解析逻辑分三步先用正则找出所有TOOL...END块然后对每个块尝试 JSON 解析最后校验工具名和参数。任何一步失败都不直接崩溃而是把错误信息作为 Observation 回填给模型让它自己修正。import re, json def parse_tool_call(text): pattern rTOOL(.*?)END matches re.findall(pattern, text, re.DOTALL) calls [] for m in matches: try: data json.loads(m.strip()) calls.append(data) except json.JSONDecodeError as e: calls.append({error: fJSON解析失败: {e}, raw: m}) return calls注意re.DOTALL这个参数没有它跨行的 JSON 就匹配不到。这个坑我踩过模型输出的 JSON 一旦换行正则就失效排查了半天才发现是标志位的问题。3.4 循环控制与终止条件循环不能无限跑必须设上限。我一般设max_iterations8超过就强制结束并返回当前最好的答案。为什么是 8因为实测下来绝大多数任务 3 到 5 轮就能解决8 轮足够覆盖复杂场景又不至于让用户等太久。终止条件有三个模型输出了不含工具调用的内容正常结束、达到最大轮数强制结束、连续两轮工具调用完全相同防止死循环。第三个条件特别重要我遇到过模型卡在一个工具上反复调用的情况没有这个保护就会一直烧 token。提示连续相同调用的判断要比较工具名和参数只比工具名会误伤那些参数不同但工具相同的正常调用。3.5 消息历史的组织方式历史消息怎么拼直接影响模型的表现。我的做法是维护一个列表每轮把模型输出和工具结果都追加进去但对工具结果做截断。为什么要截断因为有些工具返回的数据特别长比如查一个网页返回几万字全塞进上下文会迅速撑爆 token 限制。我的策略是超过 2000 字符的结果保留前 1500 和后 500中间用省略号代替。这样既保留了关键信息又控制了长度。另外历史消息里我会给工具结果加上明确的标记比如[工具结果]让模型清楚知道这段是外部数据而不是它自己说的。4. 实操过程与核心环节实现4.1 环境准备与依赖环境很简单Python 3.8 以上就行核心依赖只有requests调模型 API和标准库的re、json。不需要任何 Agent 框架这也是这套方案的一个优势——零框架依赖代码全透明。pip install requests如果你用的是本地模型把 API 调用换成对应的 SDK 即可逻辑完全一样。我特意不引入 LangChain 之类的框架因为框架会隐藏太多细节出问题的时候你根本不知道是哪一层挂了。自己写一遍每个环节都清清楚楚。4.2 模型调用封装模型调用我封装成一个函数输入是消息列表输出是文本。这里要注意几个参数temperature设低一点0.1 到 0.3 之间因为 Agent 需要稳定输出不需要创意max_tokens要留够工具调用的 JSON 加上思考内容一般 2000 起步。def call_model(messages, temperature0.2, max_tokens2000): payload { model: your-model, messages: messages, temperature: temperature, max_tokens: max_tokens } resp requests.post(API_URL, jsonpayload, headersHEADERS) return resp.json()[choices][0][message][content]为什么 temperature 要低因为结构化输出对格式稳定性要求极高温度高了模型容易发挥把 JSON 格式写歪。这个参数我调过很多次0.2 是稳定性和灵活性的平衡点。4.3 工具执行与异常兜底工具执行环节我用try/except包住每一次调用任何异常都转成字符串返回给模型而不是让程序崩溃。def execute_tool(name, args): if name not in TOOLS: return f错误工具 {name} 不存在 try: return str(TOOLS[name][func](**args)) except Exception as e: return f工具执行失败{e}这个设计的关键在于把错误变成 Observation 的一部分。模型看到工具执行失败缺少参数 city下一轮往往就能自己修正。这比直接抛异常中断整个流程要优雅得多也更符合 Agent自主纠错的理念。4.4 完整主循环实现把上面这些拼起来主循环大概长这样def run_agent(user_input, max_iter8): messages [ {role: system, content: build_system_prompt()}, {role: user, content: user_input} ] last_call None for i in range(max_iter): output call_model(messages) messages.append({role: assistant, content: output}) calls parse_tool_call(output) if not calls: return output # 最终答案 for call in calls: if error in call: result call[error] else: result execute_tool(call[name], call.get(args, {})) messages.append({role: user, content: f[工具结果] {result[:2000]}}) if calls last_call: return 检测到重复调用已终止 last_call calls return 达到最大轮数返回当前结果这段代码不到 30 行但涵盖了完整的 ReAct 循环。你可以直接拿去改。注意result[:2000]那个截断前面提过是防止上下文爆炸的关键。4.5 一个完整的运行示例假设用户问北京今天天气怎么样如果温度超过 30 度就提醒我带伞。第一轮模型输出思考过程然后抛出TOOL{name: get_weather, args: {city: 北京}}END。代码解析后调用天气工具返回北京今天 32 度晴。第二轮模型看到结果判断 32 大于 30输出最终答案北京今天 32 度建议带伞。整个过程两轮结束token 消耗可控逻辑清晰。如果天气工具返回的是查询失败模型第二轮就会尝试换个城市名或者告诉用户查询失败这就是结构化方案的可控性体现。4.6 参数选择的经验值我把几个关键参数的经验值整理成表方便你直接参考。参数推荐值说明temperature0.2结构化输出需要稳定max_tokens2000留足思考和 JSON 空间max_iterations8覆盖复杂任务又不失控工具结果截断2000 字符平衡信息量和上下文重试次数2解析失败时重试这些值不是拍脑袋定的是我在不同任务上反复测试后收敛出来的。当然具体项目要具体调整比如你的工具返回数据特别短截断阈值可以放宽。5. 常见问题与排查技巧实录5.1 模型不按格式输出怎么办这是最常见的问题。模型有时候会忘记标记直接输出 JSON或者把标记写错。我的排查顺序是先看 Prompt 里的格式约定够不够醒目再看 temperature 是不是太高最后看是不是模型能力太弱。解决办法有几个层次。第一在 Prompt 里把格式示例放在最显眼的位置甚至重复两遍。第二解析失败时不要直接报错而是把格式错误请严格按TOOL...END输出作为 Observation 回填让模型重试。第三如果某个模型实在不听话可以在解析时做容错比如同时匹配[TOOL]和TOOL两种标记。5.2 工具参数解析失败的排查参数解析失败通常有三种原因JSON 格式错误、参数名不对、参数类型不对。我一般会打印原始输出肉眼看一下模型到底写了什么。有个高频坑模型喜欢在 JSON 里加注释或者尾随逗号这些都不是合法 JSON。解决办法是在解析前做一次清洗用正则去掉//注释和多余的逗号。另一个坑是模型把数字写成字符串比如{count: 5}这时候要么在工具函数里做类型转换要么在 Prompt 里强调类型。5.3 死循环与重复调用死循环的表现是模型反复调用同一个工具参数也一样。原因通常是工具返回的结果模型不满意但它又不知道怎么办只能重试。我的处理是在主循环里记录上一次的调用如果完全相同就终止。同时在 Prompt 里加一条规则如果工具返回的结果无法解决问题请直接告诉用户不要重复调用同一个工具。 这条规则能显著减少死循环。5.4 上下文超长的处理长对话或者工具返回大数据时上下文会迅速膨胀。除了前面说的截断我还会做历史压缩当消息数量超过一定阈值比如 20 条把最早的几轮工具调用和结果合并成一句摘要比如之前查询过北京天气结果是 32 度。这个压缩逻辑要小心别把关键信息压没了。我的原则是保留最终答案和最近三轮的完整记录更早的只保留结论。5.5 常见问题速查表问题现象可能原因解决方向模型不输出工具标记Prompt 约束弱强化格式示例降低温度JSON 解析失败格式不合法清洗注释和逗号回填错误重试工具名不存在模型幻觉校验工具名回填可用清单反复调用同一工具结果不满意检测重复Prompt 加规则上下文超长历史堆积截断结果压缩历史响应特别慢轮数过多降低 max_iterations5.6 我踩过的几个真实坑第一个坑是正则贪婪匹配。一开始我用的.*而不是.*?结果模型输出多个工具调用时正则把中间所有内容都吞进了一个匹配导致解析全乱。加上问号变成非贪婪后就好了。第二个坑是工具结果里的特殊字符。有个工具返回的内容里带了END这个字符串直接把解析搞崩了。后来我在回填前对结果做了转义把标记字符替换掉。第三个坑是多轮对话的状态污染。用户连续问两个问题时如果不清空历史第二个问题会受第一个问题影响。我的做法是给每个独立会话维护独立的消息列表不要复用。注意工具返回的内容一定要做转义处理尤其是包含你自定义标记字符的情况否则解析层会被污染。6. 结构化 Agent 的扩展与优化方向6.1 多工具并行调用的支持现在的实现是一轮调一个工具但其实可以扩展成一轮调多个。模型输出多个TOOL块代码解析后并行执行再把所有结果一起回填。这样能显著减少轮数适合那些需要同时查多个数据源的场景。并行执行用 Python 的concurrent.futures就行注意工具函数要线程安全。回填的时候给每个结果标上对应的工具名避免模型搞混。6.2 工具结果的二次加工有些工具返回的是原始数据直接给模型效果不好。可以在回填前做一层加工比如把 JSON 转成自然语言描述把长列表截取前几条。这层加工逻辑放在工具函数里还是放在回填环节取决于你的复用需求。我一般放在工具函数里因为不同工具需要的加工方式不一样。6.3 记忆机制的引入通用 Agent 加上记忆会强很多。最简单的记忆是维护一个事实库把用户提到过的关键信息存下来每轮拼进 Prompt。复杂一点可以用向量检索把历史对话做嵌入需要时召回相关片段。不过记忆机制要谨慎存太多会污染上下文存太少又没用。我的经验是只存用户明确陈述的事实和 Agent 得出的结论中间过程不存。6.4 安全边界的设计Agent 能调用工具就意味着有执行副作用的风险。我的做法是给工具分级只读工具随便调写操作工具必须加确认。比如删除文件、发送请求这类工具执行前要么让用户确认要么在 Prompt 里限制调用条件。另外工具参数要做校验防止模型被诱导执行危险操作。比如文件路径参数要检查是否在允许的目录范围内。这些校验放在工具函数入口不要依赖模型自觉。6.5 性能优化的几个点性能优化主要从三个方向入手。第一是减少轮数通过优化 Prompt 让模型一次想清楚别来回试探。第二是缓存相同参数的相同工具调用直接返回缓存结果避免重复计算。第三是流式输出虽然结构化解析和流式有点冲突但可以先把文本流式吐给用户工具调用部分缓冲后再解析体验会好很多。我实测下来缓存对多轮任务的提速最明显尤其是那些会重复查询相同数据的场景。7. 写在最后的一点个人体会这套无 Tool Calling 的结构化 Agent 方案我从最初的原型到稳定运行前后迭代了大概五六个版本。最大的感受是把控制权握在自己手里比依赖模型的黑盒能力要踏实得多。模型会换、接口会变、格式会调整但你自己写的解析和执行逻辑永远是你自己的。如果你正准备做 Agent 开发我建议先用这套方案跑通一个最小闭环哪怕只有一两个工具。跑通之后你会发现Agent 的本质没那么神秘无非是让模型思考、让代码执行、把结果喂回去这个循环。理解了这一点再去看那些复杂的框架就会觉得它们只是把这套逻辑包装得更花哨而已。最后分享一个小技巧调试 Agent 的时候把每一轮的完整 Prompt 和模型输出都打到日志里出问题的时候一眼就能看出是哪一轮、哪个环节挂了。这个习惯帮我省了无数排查时间。