ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

1. 为什么我要绕开 Tool Calling 做 Agent

1.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、固定标记)返回内容,本地可解析。
  • ReAct:Reasoning + 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_recent=5): if len(history) <= max_recent: return history recent = history[-max_recent:] older = history[:-max_recent] summary = summarize(older) return [{"role": "system", "content": f"历史摘要:{summary}"}] + recent

summarize可以用一个小模型来做,也可以用规则提取关键信息。我一般用规则,因为快且免费。

3.4 工具描述怎么写才不容易被误选

工具描述是模型选工具的唯一依据,写得好坏直接决定准确率。我踩过的坑是:描述太短,模型分不清相似工具;描述太长,模型抓不住重点。

我的写法是一句话功能 + 参数说明 + 使用场景,控制在 50 字以内:

- weather_query(city, date):查询指定城市指定日期的天气。适用于用户问天气、温度、是否下雨等。 - search_web(query):搜索网络信息。适用于用户问实时新闻、未知事实、需要外部数据的问题。

关键技巧:在描述里明确“什么时候用”。模型选错工具,往往不是因为它不懂工具功能,而是因为它不知道边界。加上“适用于”这句话,准确率能提升不少。

4. 实操过程与核心环节实现

4.1 环境准备与依赖

这套方案对环境的依赖极低,Python 3.8+ 就够,不需要任何 Agent 框架。我用的库只有三个:

pip install requests openai pyyaml
  • requests:调模型 API 和外部工具。
  • openai:如果你用兼容 OpenAI 协议的模型,这个库最方便。
  • pyyaml:配置文件用 YAML 写,比 JSON 好读。

如果你用其他模型,把openai换成对应的 SDK 就行,核心逻辑不变。

4.2 工具注册与实现

先定义工具。我以天气查询和文件读取为例:

import json def weather_query(city, date="today"): # 实际项目里这里调真实 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", encoding="utf-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, history=None): 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 "达到最大轮数,任务未完成。", history

MAX_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, retries=3): for i in range(retries): try: response = client.chat.completions.create( model="your-model-name", messages=[{"role": "user", "content": prompt}], temperature=0.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 模型不按格式输出怎么办

这是最常见的问题,尤其是用小模型或者新模型时。排查顺序:

  1. 检查 Prompt 里的格式说明是否清晰。我见过有人把格式说明写在最后,模型根本没注意。格式说明要放在系统指令里,靠前。
  2. 检查是否有示例。模型是模仿高手,给一两个正确示例,格式遵守率会大幅提升。
  3. 降低 temperature。如果模型太放飞,先降到 0.1 试试。
  4. 加格式校验重试。解析失败时,把失败原因告诉模型,让它重来。

我一般会在 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 的兼容性折磨,不妨试试这个思路,改造成本不高,收益很直接。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进