资讯详情

资讯详情

告别Tool Calling:用纯Prompt打造稳定通用的结构化Agent

1. 为什么我要绕开 Tool Calling 做 Agent1.1 一个被过度神化的机制过去大半年几乎所有人聊 Agent 都会先聊 Tool Calling。模型厂商把它包装成“Agent 的原生能力”框架文档把它放在第一页面试题里也必问“你怎么做函数调用”。我自己也踩过这个坑一开始做 Agent第一反应就是接一套 Tool Calling 的流程把工具定义成 JSON Schema 丢给模型等它返回结构化调用指令再在本地执行、把结果塞回上下文。跑通 Demo 很快但真到业务里就发现一堆问题。最典型的是三件事第一模型对工具的选择不稳定同一个问题换个问法它可能选 A 工具也可能选 B 工具甚至干脆不选第二工具一多Schema 描述就膨胀光工具定义就吃掉几千 token成本肉眼可见地涨第三也是最要命的很多模型服务对“函数调用”这种能力有额外限制一旦你的 prompt 里带了某些触发词直接给你返回invalid prompt: your prompt was flagged as potentially violating our usage policy整个链路就断了。所以我后来换了个思路不依赖模型原生的 Tool Calling而是用纯 Prompt 把“结构化输出”这件事做扎实让 Agent 自己按约定格式吐结果我在本地解析。这就是这篇要讲的“无 Tool Calling 的结构化通用 Agent”。1.2 这个 Agent 到底解决什么问题一句话概括它让模型只负责“想”和“按格式写”不负责“调”。所有工具的执行、状态的流转、上下文的拼接全部由本地代码控制。这样做的好处很直接模型无关不管你是用哪家的模型只要它能稳定输出文本就能跑。不需要模型支持 function calling也不需要特定的 API 参数。成本可控工具描述可以压缩成一段自然语言说明而不是每个工具一份 JSON Schema。调试友好模型输出的是可读文本出问题直接看日志就知道它想干什么不用去猜它为什么选错工具。安全边界清晰模型永远不直接触发任何外部动作所有执行都在本地白名单里过一遍避免“模型一句话就把数据库删了”这种事故。适合谁来参考如果你正在做 Agent 应用开发被 Tool Calling 的兼容性和稳定性折磨过或者你想做一个能跑在任意模型上的通用 Agent那这套思路可以直接抄。1.3 核心关键词先对齐在往下讲之前先把几个词说清楚不然后面容易混Tool Calling模型原生支持的“返回函数名参数”的能力通常由厂商 API 提供。结构化输出模型按约定格式比如 JSON、YAML、固定标记返回内容本地可解析。ReActReasoning Acting 的循环范式模型先想再动动完再想。Prompt 编排把系统指令、工具说明、历史对话、当前任务拼成一段完整输入。我这套方案本质上是ReAct 的简化版 结构化输出的强化版去掉了对 Tool Calling 的依赖用 Prompt 约束替代 API 约束。2. 整体设计思路与方案选型2.1 为什么不用 Tool Calling 也能做 Agent很多人有个误解觉得 Agent 必须靠 Tool Calling 才能“动起来”。其实 Agent 的本质是一个循环观察 → 思考 → 行动 → 再观察。Tool Calling 只是“行动”这一环的一种实现方式不是唯一方式。我可以用一个生活类比Tool Calling 像是你给助理一个对讲机他按按钮说话系统自动识别指令而无 Tool Calling 像是你给助理一张表格让他每次把“我要做什么、参数是什么”填在固定格子里你人工读表执行。后者慢一点但更可控也不依赖对讲机的型号。具体到实现上我选择让模型输出一段带固定标记的文本比如[THOUGHT] 用户想知道今天的天气我需要调用天气查询工具。 [ACTION] weather_query [ARGS] {city: 杭州, date: today}本地代码用正则或字符串切分把ACTION和ARGS抠出来执行对应函数再把结果拼回下一轮上下文。整个过程模型只输出文本不碰任何 API 层面的函数调用。2.2 方案对比三种主流做法为了让你看清取舍我把常见的三种做法列个表方案依赖稳定性成本调试难度适用场景原生 Tool Calling模型 API 支持中受模型影响大高Schema 占 token中黑盒模型固定、工具少纯 Prompt 结构化无高可本地校验低描述可压缩低输出可读多模型、工具多混合模式部分依赖中高中中过渡期项目我选纯 Prompt 结构化核心原因是我不想被任何一家模型绑死。业务里经常遇到某个模型突然限流、涨价、或者 prompt 被误判这时候能一键换模型是刚需。Tool Calling 一旦写死换模型就要重写工具层成本太高。2.3 结构化输出的格式设计格式设计是这套方案的地基。我试过 JSON、YAML、XML、自定义标记四种最后落在自定义标记 JSON 参数的组合上。为什么不用纯 JSON因为模型在长上下文里容易把 JSON 括号写错尤其是嵌套深的时候解析失败率明显上升。自定义标记的好处是边界清晰[ACTION]和[ARGS]这种标记模型很难写错即使写错也容易用正则兜底。为什么参数还用 JSON因为参数需要结构化JSON 是模型最熟悉的格式而且 Python 的json.loads解析起来最稳。实测下来这种组合的解析成功率能到 98% 以上剩下 2% 用重试和兜底逻辑处理。格式约定如下[THOUGHT] 模型的思考过程自然语言 [ACTION] 工具名必须是白名单里的 [ARGS] JSON 格式的参数 [FINAL] 最终回答当不需要工具时使用模型每轮只能输出一个ACTION或一个FINAL不能同时输出。这个约束很重要否则模型会既想调工具又想直接回答导致解析歧义。2.4 工具白名单与安全边界无 Tool Calling 最大的风险是模型“乱写工具名”。如果本地代码直接eval或者动态导入那就是灾难。我的做法是维护一个显式的工具注册表模型输出的ACTION必须在这个表里否则直接返回“工具不存在”让模型重新想。TOOL_REGISTRY { weather_query: weather_query, search_web: search_web, read_file: read_file, write_file: write_file, } def execute_action(action_name, args): if action_name not in TOOL_REGISTRY: return f错误工具 {action_name} 不存在请从可用工具中选择。 return TOOL_REGISTRY[action_name](**args)这个白名单机制看起来简单但它是整个 Agent 安全性的核心。模型再聪明也只能在白名单里选越权操作从根上被堵死。3. 核心细节解析与实操要点3.1 Prompt 编排的四个层次Prompt 不是随便拼的我把它分成四层每层职责明确第一层系统指令。定义 Agent 的身份、行为准则、输出格式。这一层是固定的不随对话变化。第二层工具说明。用自然语言描述每个工具能做什么、参数是什么。注意这里不用 JSON Schema而是用人类可读的说明模型理解起来更顺。第三层历史上下文。之前的对话和工具调用结果。这里要做裁剪不能无限堆否则 token 爆炸。第四层当前任务。用户最新的一句话或者上一轮工具返回的结果。四层拼起来大概长这样你是一个通用 Agent可以调用工具完成任务。 可用工具 - weather_query(city, date)查询指定城市指定日期的天气。 - search_web(query)搜索网络信息。 - read_file(path)读取本地文件内容。 输出格式要求 每轮你必须输出以下之一 [THOUGHT] ... [ACTION] 工具名 [ARGS] {...} 或 [THOUGHT] ... [FINAL] 最终回答 历史对话 用户帮我查下杭州天气 助手[THOUGHT] 需要调用天气工具 [ACTION] weather_query [ARGS] {city: 杭州, date: today} 工具返回杭州今天晴25度 当前任务 用户那明天呢这种编排的好处是模型很清楚“我现在该干什么”不会在格式上纠结。3.2 解析器的容错设计模型输出不可能 100% 规范所以解析器必须容错。我总结了几个常见错误和对应处理错误类型示例处理方式标记缺失只写了[ACTION]没写[ARGS]视为参数为空尝试执行JSON 格式错参数里多了逗号或少了引号用json.loads失败后尝试修复多个 ACTION一轮输出了两个工具调用只取第一个忽略后续工具名大小写Weather_Query统一转小写后匹配混入解释文字[ACTION] weather_query 因为...正则只取标记后第一个词解析器核心逻辑import re import json def parse_output(text): action_match re.search(r\[ACTION\]\s*(\w), text) args_match re.search(r\[ARGS\]\s*(\{.*?\}), text, re.DOTALL) final_match re.search(r\[FINAL\]\s*(.), text, re.DOTALL) if final_match: return {type: final, content: final_match.group(1).strip()} if action_match: action action_match.group(1).lower() args {} if args_match: try: args json.loads(args_match.group(1)) except json.JSONDecodeError: args {_raw: args_match.group(1)} return {type: action, action: action, args: args} return {type: unknown, raw: text}这个解析器我用了很久实测能覆盖 95% 以上的异常情况。剩下 5% 走重试逻辑把解析失败的原因告诉模型让它重新输出一次。3.3 上下文裁剪策略Agent 跑久了上下文会越来越长。如果不裁剪要么 token 超限要么成本失控。我的裁剪策略是保留最近 N 轮 关键历史摘要。具体做法最近 5 轮对话完整保留。更早的对话压缩成一句话摘要比如“用户之前问过杭州天气已返回晴天”。工具返回的长文本只保留前 500 字符超出部分截断并标注。def trim_context(history, max_recent5): if len(history) max_recent: return history recent history[-max_recent:] older history[:-max_recent] summary summarize(older) return [{role: system, content: f历史摘要{summary}}] recentsummarize可以用一个小模型来做也可以用规则提取关键信息。我一般用规则因为快且免费。3.4 工具描述怎么写才不容易被误选工具描述是模型选工具的唯一依据写得好坏直接决定准确率。我踩过的坑是描述太短模型分不清相似工具描述太长模型抓不住重点。我的写法是一句话功能 参数说明 使用场景控制在 50 字以内- weather_query(city, date)查询指定城市指定日期的天气。适用于用户问天气、温度、是否下雨等。 - search_web(query)搜索网络信息。适用于用户问实时新闻、未知事实、需要外部数据的问题。关键技巧在描述里明确“什么时候用”。模型选错工具往往不是因为它不懂工具功能而是因为它不知道边界。加上“适用于”这句话准确率能提升不少。4. 实操过程与核心环节实现4.1 环境准备与依赖这套方案对环境的依赖极低Python 3.8 就够不需要任何 Agent 框架。我用的库只有三个pip install requests openai pyyamlrequests调模型 API 和外部工具。openai如果你用兼容 OpenAI 协议的模型这个库最方便。pyyaml配置文件用 YAML 写比 JSON 好读。如果你用其他模型把openai换成对应的 SDK 就行核心逻辑不变。4.2 工具注册与实现先定义工具。我以天气查询和文件读取为例import json def weather_query(city, datetoday): # 实际项目里这里调真实 API这里用模拟数据 mock_data { 杭州: {today: 晴25度, tomorrow: 多云23度}, 北京: {today: 阴18度, tomorrow: 小雨16度}, } return mock_data.get(city, {}).get(date, 暂无数据) def read_file(path): try: with open(path, r, encodingutf-8) as f: return f.read()[:2000] except Exception as e: return f读取失败{e} TOOL_REGISTRY { weather_query: weather_query, read_file: read_file, } TOOL_DESCRIPTIONS - weather_query(city, date)查询指定城市指定日期的天气。适用于用户问天气、温度、是否下雨等。 - read_file(path)读取本地文件内容。适用于用户要求查看某个文件。 注意read_file我做了截断防止模型读到超大文件把上下文撑爆。这是实操中必须加的防护。4.3 主循环实现主循环是 Agent 的心脏逻辑是拼 Prompt → 调模型 → 解析输出 → 执行工具 → 拼回上下文 → 再循环直到模型输出FINAL或达到最大轮数。MAX_TURNS 10 def run_agent(user_input, historyNone): if history is None: history [] history.append({role: user, content: user_input}) for turn in range(MAX_TURNS): prompt build_prompt(history) raw_output call_model(prompt) parsed parse_output(raw_output) if parsed[type] final: history.append({role: assistant, content: raw_output}) return parsed[content], history if parsed[type] action: action parsed[action] args parsed[args] result execute_action(action, args) history.append({role: assistant, content: raw_output}) history.append({role: tool, content: f工具 {action} 返回{result}}) continue # 解析失败让模型重试 history.append({role: assistant, content: raw_output}) history.append({role: user, content: 你的输出格式不对请按约定格式重新输出。}) return 达到最大轮数任务未完成。, historyMAX_TURNS设 10 是个经验值。设太小复杂任务跑不完设太大万一模型陷入死循环成本和延迟都受不了。10 轮对大多数任务够用。4.4 Prompt 构建函数def build_prompt(history): system f你是一个通用 Agent可以调用工具完成任务。 可用工具 {TOOL_DESCRIPTIONS} 输出格式要求 每轮你必须输出以下之一 [THOUGHT] 你的思考 [ACTION] 工具名 [ARGS] {{JSON参数}} 或 [THOUGHT] 你的思考 [FINAL] 最终回答 注意 1. 每轮只能输出一个 ACTION 或一个 FINAL。 2. ACTION 必须是可用工具里的名字。 3. ARGS 必须是合法 JSON。 trimmed trim_context(history) parts [system] for msg in trimmed: parts.append(f{msg[role]}{msg[content]}) return \n.join(parts)这里有个细节{{JSON参数}}里的双大括号是因为用了 f-string实际输出是{JSON参数}。这种小坑不注意就会导致格式说明错乱。4.5 模型调用与重试import time def call_model(prompt, retries3): for i in range(retries): try: response client.chat.completions.create( modelyour-model-name, messages[{role: user, content: prompt}], temperature0.3, ) return response.choices[0].message.content except Exception as e: if i retries - 1: raise time.sleep(2 ** i) return temperature设 0.3 是权衡结果。设 0 太死板模型容易卡在某个格式上设 1 太随机格式稳定性下降。0.3 是我实测下来格式最稳、又不失灵活性的值。重试用指数退避2 ** i分别是 1、2、4 秒。这是处理限流和网络抖动的标准做法。4.6 完整跑一遍if __name__ __main__: answer, hist run_agent(杭州今天天气怎么样) print(answer)预期输出类似杭州今天晴25度。如果模型第一轮输出[ACTION] weather_query [ARGS] {city: 杭州, date: today}本地执行后返回“晴25度”第二轮模型就会输出[FINAL] 杭州今天晴25度。。整个过程模型没有做任何原生函数调用全靠文本约定但效果和 Tool Calling 几乎一样。5. 常见问题与排查技巧实录5.1 模型不按格式输出怎么办这是最常见的问题尤其是用小模型或者新模型时。排查顺序检查 Prompt 里的格式说明是否清晰。我见过有人把格式说明写在最后模型根本没注意。格式说明要放在系统指令里靠前。检查是否有示例。模型是模仿高手给一两个正确示例格式遵守率会大幅提升。降低 temperature。如果模型太放飞先降到 0.1 试试。加格式校验重试。解析失败时把失败原因告诉模型让它重来。我一般会在 Prompt 里加一个 few-shot 示例示例 用户北京天气 助手[THOUGHT] 需要查天气 [ACTION] weather_query [ARGS] {city: 北京, date: today} 工具返回阴18度 助手[THOUGHT] 已获取结果 [FINAL] 北京今天阴18度。这个示例一加格式错误率能降一半以上。5.2 工具选错怎么排查工具选错通常有三个原因描述边界不清两个工具功能相似模型分不清。解决方法是把“适用于”写清楚。工具太多超过 10 个工具模型选择准确率明显下降。解决方法是分组或者用两级选择先选类别再选工具。上下文干扰历史对话里有旧工具调用模型被带偏。解决方法是裁剪上下文或者在新任务开始时清空历史。我实测下来工具数量控制在 5 到 8 个时准确率最高。超过 10 个就要考虑拆分 Agent 了。5.3 上下文超限怎么处理上下文超限的表现是模型开始胡言乱语或者直接报错。处理方式现象原因解决模型忘记之前说过的话上下文被截断增加摘要层保留关键信息响应变慢token 太多裁剪历史工具返回截断报 token 超限单次输入太长分块处理或换长上下文模型我的经验是工具返回结果一定要截断。很多工具返回一大段 JSON 或者 HTML直接塞进上下文就是灾难。截断到 500 到 1000 字符保留关键信息即可。5.4 模型服务返回 invalid prompt 怎么办这个问题我遇到过几次通常是 prompt 里某些词触发了服务方的安全策略。排查方法逐段注释法把 prompt 分成几段逐段测试找出触发的那段。替换敏感词有些词在特定语境下会被误判换成同义词试试。简化 prompt如果找不到具体原因把 prompt 精简去掉不必要的修饰。我踩过的坑是工具描述里写了某些看起来像指令的词被服务方判定为“试图绕过限制”。后来我把工具描述改成纯功能说明问题就消失了。5.5 常见问题速查表问题可能原因快速解决格式解析失败模型没按约定输出加 few-shot 示例降 temperature工具选错描述不清或工具太多明确“适用于”控制工具数量上下文超限历史太长或工具返回太大裁剪历史截断工具返回响应慢token 多或模型慢换小模型减少上下文死循环模型反复调同一工具设 MAX_TURNS检测重复调用invalid prompt触发服务方策略逐段排查替换敏感表述5.6 几个独家避坑技巧技巧一给工具调用加去重。如果模型连续两轮调同一个工具、同样的参数直接返回“已调用过请基于已有结果回答”避免死循环。技巧二工具返回结果加前缀。比如工具 weather_query 返回晴25度。这样模型能清楚知道这是工具结果不是用户说的话。技巧三FINAL 输出加校验。如果模型输出[FINAL]但内容为空视为无效让它重来。我遇到过模型输出空 FINAL 的情况不加校验会直接返回空字符串给用户。技巧四日志要打全。每轮的 prompt、模型原始输出、解析结果、工具执行结果全部记日志。出问题时看日志比猜快十倍。技巧五模型切换要测格式。换模型后先跑一遍格式测试集确认新模型能稳定输出约定格式再上业务。不同模型对格式的敏感度差异很大。6. 性能优化与扩展方向6.1 减少 token 消耗的三个手段Agent 跑起来后token 消耗是大头。我总结了三个有效手段第一工具描述压缩。把工具描述从 100 字压到 50 字10 个工具就能省 500 token。压缩的原则是保留“功能参数适用场景”去掉客套话。第二历史摘要。用规则或小模型把旧对话压成一句话。我实测过20 轮对话压成 5 轮加摘要token 能省 60% 以上效果几乎无损。第三工具返回截断。工具返回的长文本只保留关键部分。比如搜索返回 10 条结果只保留前 3 条的标题和摘要。6.2 并行工具调用有些任务需要同时调多个工具比如“查杭州和北京的天气”。原生 Tool Calling 支持并行调用无 Tool Calling 也能做方法是让模型一轮输出多个 ACTION[THOUGHT] 需要查两个城市 [ACTION] weather_query [ARGS] {city: 杭州} [ACTION] weather_query [ARGS] {city: 北京}解析器改成提取所有 ACTION本地并行执行结果一起拼回上下文。这样能省一轮模型调用延迟降低明显。6.3 多 Agent 协作单个 Agent 工具多了会乱这时候可以拆成多个 Agent每个负责一个领域。比如一个天气 Agent、一个文件 Agent、一个搜索 Agent由一个调度 Agent 分发任务。调度 Agent 的输出格式可以设计成[ROUTE] weather_agent [TASK] 查杭州今天天气本地根据ROUTE把任务转给对应 Agent结果再汇总。这种架构适合工具超过 15 个的复杂场景。6.4 结构化数据建模的延伸这套方案的核心是“结构化输出”这个思路可以延伸到很多地方。比如文档结构化解析让模型把非结构化文档转成 JSON本地入库。数据抽取从聊天记录里抽取结构化字段做分析。表单填充让模型按表单字段输出本地校验后提交。我最近在做一个项目就是用这套思路把用户的口语化需求转成结构化查询条件准确率比传统 NLP 方法高不少因为大模型对语义的理解确实强。7. 我在这套方案上踩过的坑7.1 不要迷信模型的“聪明”刚开始做的时候我总觉得模型能理解我的意图格式说明写得很随意。结果就是模型各种不按套路出牌。后来我明白了模型不是人它不会“揣摩”你的意思它只会模仿你给的模式。格式说明要像写给机器看的一样精确示例要给足。7.2 工具描述不是越详细越好我一度把工具描述写得非常详细每个参数都解释一遍。结果模型反而选错工具因为描述太长它抓不住重点。后来我改成“一句话功能 适用场景”准确率反而上去了。这让我意识到给模型的信息要精炼不是越多越好。7.3 重试逻辑要有上限我最早写的重试是无限循环只要解析失败就重试。结果有一次模型陷入死循环一直输出错误格式程序跑了半小时才被我手动停掉。后来我加了重试上限最多 3 次超过就返回错误。这个教训很深刻任何循环都要有退出条件。7.4 日志比调试器好用Agent 是黑盒调试器很难用。我后来养成了打日志的习惯每轮的输入输出全记下来。出问题时翻日志比单步调试快得多。而且日志还能用来分析模型的错误模式针对性优化 Prompt。7.5 换模型要重新测我以为 Prompt 写好了换模型应该无缝切换。结果换了一个模型后格式遵守率从 98% 掉到 70%。后来我明白不同模型对 Prompt 的敏感度不同换模型必须重新测格式。现在我维护了一个格式测试集换模型先跑一遍通过了才上业务。这套无 Tool Calling 的结构化 Agent 方案我用了大半年跑过天气查询、文件操作、搜索汇总、数据抽取几个场景稳定性比原生 Tool Calling 好不少。核心就一句话把模型当文本生成器用别当函数调用器用。格式约定写清楚解析器做扎实白名单守住安全边界剩下的就是调优的事。如果你也在被 Tool Calling 的兼容性折磨不妨试试这个思路改造成本不高收益很直接。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →