1. 为什么我要绕开 Tool Calling 做 Agent
1.1 一个被过度神化的机制
过去一年,几乎所有人聊 Agent 都会先聊 Tool Calling。模型厂商把它包装成 Agent 的标配能力,好像没有函数调用就不配叫智能体。我自己也踩过这个坑:早期做任务型 Agent,第一反应就是接一套 Tool Calling 协议,把搜索、计算、数据库查询全注册成工具,然后让模型自己决定调哪个。
结果呢?上线三天就翻车。模型在长对话里开始"幻觉调用"——明明没有的工具它硬编一个名字出来;参数格式偶尔飘掉,JSON 少个括号整个链路就崩;更麻烦的是,不同模型对 Tool Calling 的支持程度参差不齐,换一个模型就得重写一遍适配层。那段时间我最大的感受是:Tool Calling 把控制权交给了模型,但模型并不总是靠谱。
后来我换了个思路:如果我不依赖模型原生的函数调用能力,而是用纯文本 Prompt 让模型输出结构化结果,再由我自己的代码去解析和执行,会怎样?这就是这篇要聊的"无 Tool Calling 的结构化通用 Agent"。核心就一句话——用 Prompt 约束输出格式,用代码做确定性执行,把不确定性关进笼子里。
1.2 这套方案到底解决什么问题
先说清楚它适合谁。如果你在做下面这几类事情,这套思路会非常顺手:
- 需要跨多个模型供应商,不想被某家的 Tool Calling 协议绑死;
- 任务流程相对固定,但每一步的输入输出需要模型来"填内容";
- 对稳定性要求高,不能接受模型偶尔抽风导致整个流程崩溃;
- 想快速验证 Agent 想法,不想先花两天搭一套工具注册框架。
它不适合的场景也很明确:需要模型自主探索、动态决定调用几十个未知工具的那种开放式 Agent。那种场景 Tool Calling 确实更自然。但如果你的任务是"读需求 → 拆步骤 → 逐步执行 → 汇总结果"这种结构化流程,无 Tool Calling 的方案反而更稳、更可控、更好调试。
我把它叫"结构化通用 Agent",是因为它有两个特征:结构化指每一步的输入输出都有明确的格式约定;通用指这套骨架不绑定具体任务,换个 Prompt 就能干别的活。下面我把整套设计和实现拆开讲。
2. 整体架构:把 Agent 拆成"思考层"和"执行层"
2.1 核心思路:模型只负责生成文本,代码负责一切决策
传统 Tool Calling 的 Agent 里,模型既要想"下一步干什么",又要输出"调用哪个工具、传什么参数",决策和执行混在一起。我的做法是把这两件事彻底分开:
- 思考层:模型只做一件事——根据当前上下文,输出一段符合约定格式的文本。这段文本描述"我打算做什么"。
- 执行层:我的 Python 代码解析这段文本,判断意图,执行对应的确定性操作,把结果拼回上下文,再喂给模型进入下一轮。
这么拆的好处是,模型永远不直接"碰"真实系统。它输出的只是文本,文本要经过我的解析器校验,校验通过才执行。模型幻觉出一个不存在的动作?解析器直接拒绝,返回错误提示让它重来。这就把 Tool Calling 里那种"模型说调就调"的失控风险消掉了。
2.2 为什么用 ReAct 而不是纯 Chain
有人会问,既然流程固定,为什么不直接用 Chain(固定流水线)?因为纯 Chain 太死板,遇到需要根据中间结果调整后续步骤的任务就抓瞎。ReAct(Reasoning + Acting)的价值在于它允许"边想边做":模型先输出一段推理,再输出一个动作,代码执行后把观察结果返回,模型基于新观察继续推理。
我保留了 ReAct 的循环结构,但去掉了它对原生 Tool Calling 的依赖。具体来说,每一轮模型输出被约定成三段式:
Thought: 我现在需要先确认用户要处理的数据范围 Action: query_data ActionInput: {"table": "orders", "date_range": "last_7_days"}代码解析出Action和ActionInput,执行对应函数,然后把结果作为Observation追加回上下文。模型看到 Observation 后继续下一轮 Thought。整个循环直到模型输出Final Answer为止。
2.3 架构分层图(文字描述)
整套系统我分成四层,从上到下:
| 层级 | 职责 | 关键组件 |
|---|---|---|
| 交互层 | 接收用户输入,管理会话状态 | 会话管理器、上下文裁剪 |
| 思考层 | 调用模型生成 Thought/Action | Prompt 模板、模型客户端 |
| 解析层 | 解析模型输出,校验格式 | 正则解析器、Schema 校验 |
| 执行层 | 执行确定性操作,返回结果 | 动作注册表、异常处理 |
这个分层最大的好处是每一层都能单独测试。解析层可以拿一堆脏输出来跑单元测试,执行层可以脱离模型单独验证。Tool Calling 方案里这三层是糊在一起的,出问题很难定位到底是模型的问题还是框架的问题。
3. Prompt 设计:让模型乖乖输出结构化文本
3.1 格式约定的三条铁律
无 Tool Calling 方案成败全在 Prompt。我总结出三条必须遵守的规则:
第一,格式必须极简且唯一。不要给模型太多选择。我试过让模型在 JSON、YAML、自定义标记之间自由选,结果它每次都选不一样的,解析器直接崩溃。后来我固定用Thought/Action/ActionInput三段式,模型很快就稳定了。
第二,必须给完整示例。光描述格式没用,模型需要看到"输入长这样,输出应该长这样"的完整样例。我的 Prompt 里永远包含至少两个 few-shot 示例,一个简单任务一个复杂任务。
第三,必须明确禁止项。我会在 Prompt 里写死:"不要输出任何解释性文字在 Action 行之外"、"ActionInput 必须是单行合法 JSON"、"如果任务完成必须输出 Final Answer 开头"。这些负面约束比正面描述更能压住模型的发散倾向。
3.2 一个可直接复用的 Prompt 模板
下面是我实际在用的模板,脱敏后可以直接抄:
你是一个结构化任务执行 Agent。你可以使用以下动作: {action_descriptions} 请严格按照以下格式回应,每次只输出一个步骤: Thought: [你的推理,说明为什么做这一步] Action: [动作名称,必须是上面列出的之一] ActionInput: [单行合法 JSON,作为动作的输入] 当你认为任务已经完成时,输出: Thought: [总结推理] Final Answer: [给用户的最终答复] 规则: 1. Action 必须是上面列出的动作名称之一,不得编造。 2. ActionInput 必须是单行合法 JSON,不得包含换行。 3. 每次只输出一个 Thought/Action/ActionInput 组合。 4. 不要输出任何额外说明文字。 示例 1: 用户:帮我查一下上周的订单总数 Thought: 用户需要上周订单数,我应该调用 query_data 动作。 Action: query_data ActionInput: {"table": "orders", "metric": "count", "date_range": "last_7_days"} 示例 2: 用户:把结果整理成一句话 Thought: 我已经拿到数据,现在需要整理成自然语言。 Final Answer: 上周订单总数为 1234 单。这个模板的关键在于{action_descriptions}是动态注入的。我维护一个动作注册表,每个动作有名称、描述、参数说明,启动时拼进 Prompt。这样加新动作不用改 Prompt 主体,只改注册表。
3.3 处理"prompt 被标记"这类报错
热词里有个invalid prompt: your prompt was flagged as potentially violating our usage p,这个我遇到过。通常是因为 Prompt 里包含了某些触发词,或者 few-shot 示例的内容踩了线。我的处理经验是:
- 示例内容尽量用中性、无争议的场景,别用真实敏感数据;
- 如果某个动作描述触发了标记,把描述改得更抽象,比如把具体业务名词换成"数据表 A";
- 保留一份"最小可用 Prompt",出问题时先退回最小版本确认是内容问题还是格式问题。
注意:不同模型对 Prompt 的审查尺度不一样,同一段 Prompt 在 A 模型能过、B 模型被拒是常事。所以我的做法是把 Prompt 模板和示例内容分离,示例可以按模型替换,模板主体不动。
4. 解析层实现:把脏输出变成干净指令
4.1 解析器的核心逻辑
模型输出永远是"脏"的。它可能多打空格、可能把 JSON 写成多行、可能在 Action 后面加个句号。解析器必须足够宽容,但校验必须足够严格。我的解析流程是:
- 按行扫描,找到以
Thought:、Action:、ActionInput:、Final Answer:开头的行; - 提取每段的内容,去掉首尾空白;
- 如果发现
Final Answer,直接返回终止信号; - 否则校验 Action 是否在注册表中,ActionInput 是否能解析成 JSON;
- 任何一步失败,返回一个格式错误提示,让模型重试。
这里有个关键设计:解析失败不抛异常,而是作为 Observation 返回给模型。比如模型输出了不存在的动作,我就返回Observation: 错误:动作 xxx 不存在,可用动作有 [...]。模型看到这个提示,下一轮通常就能改对。这比直接崩溃友好太多。
4.2 用正则还是用状态机
我一开始用正则,简单直接:
import re ACTION_PATTERN = re.compile(r"^Action:\s*(.+)$", re.MULTILINE) INPUT_PATTERN = re.compile(r"^ActionInput:\s*(.+)$", re.MULTILINE) FINAL_PATTERN = re.compile(r"^Final Answer:\s*(.+)$", re.MULTILINE | re.DOTALL)但正则有个坑:如果 ActionInput 的 JSON 里本身包含换行(模型偶尔会这么干),单行匹配就断了。后来我改成状态机式的逐行扫描,遇到ActionInput:后把后续所有行拼起来直到遇到下一个标记行。这样容错性高很多。
def parse_model_output(text): lines = text.split("\n") result = {"thought": "", "action": None, "action_input": None, "final": None} current_key = None buffer = [] def flush(): if current_key and buffer: result[current_key] = "\n".join(buffer).strip() for line in lines: stripped = line.strip() if stripped.startswith("Thought:"): flush() current_key = "thought" buffer = [stripped[len("Thought:"):].strip()] elif stripped.startswith("Action:"): flush() current_key = "action" buffer = [stripped[len("Action:"):].strip()] elif stripped.startswith("ActionInput:"): flush() current_key = "action_input" buffer = [stripped[len("ActionInput:"):].strip()] elif stripped.startswith("Final Answer:"): flush() current_key = "final" buffer = [stripped[len("Final Answer:"):].strip()] else: buffer.append(line) flush() return result这段代码我用了很久,稳定性很好。它的核心思想是"标记行切换状态,非标记行累积到当前状态"。
4.3 JSON 容错处理
ActionInput 的 JSON 解析是另一个重灾区。模型经常输出这些变体:
- 单引号代替双引号:
{'table': 'orders'} - 尾部多余逗号:
{"table": "orders",} - 中文引号:
{"table":"orders"} - 缺少引号的键:
{table: "orders"}
我的处理策略是分级容错。先尝试标准json.loads,失败后依次尝试:替换单引号为双引号、去掉尾部逗号、替换中文标点。如果全失败,返回格式错误让模型重试。实测下来,这套容错能救回 80% 以上的脏输出。
实操心得:不要试图写一个"万能 JSON 修复器",那是个无底洞。容错做到能救常见错误就够了,剩下的交给模型重试。我见过有人花一周写修复器,结果模型换个版本输出风格又变了,白干。
5. 执行层:动作注册表与安全边界
5.1 动作注册表的设计
执行层的核心是一个动作注册表。每个动作是一个 Python 函数,加上元数据描述。我用装饰器来注册:
ACTIONS = {} def register_action(name, description, params_schema): def decorator(func): ACTIONS[name] = { "func": func, "description": description, "schema": params_schema, } return func return decorator @register_action( name="query_data", description="查询数据表,支持 count/sum/avg 等聚合", params_schema={"table": "str", "metric": "str", "date_range": "str"} ) def query_data(table, metric, date_range): # 实际查询逻辑 return {"result": 1234}启动时,我遍历ACTIONS生成 Prompt 里的动作描述,同时生成参数校验规则。这样加动作只需要写一个函数加一个装饰器,Prompt 和校验自动更新。
5.2 参数校验不能省
模型给的参数永远要校验。我见过模型把date_range传成"上周"这种自然语言,也见过它把数字传成字符串。校验分两层:
- 类型校验:根据 schema 检查参数类型,不对就返回错误;
- 业务校验:比如 date_range 必须是预定义的枚举值之一,table 必须在白名单里。
业务校验特别重要,它是安全边界。如果模型能传任意 table 名,那它理论上能查任何表。我的做法是维护一个白名单,只允许查注册过的表。这跟 Tool Calling 里"只暴露必要工具"是一个道理,但在无 Tool Calling 方案里,这个边界完全由我的代码控制,更可靠。
5.3 执行超时与异常兜底
每个动作执行都要有超时。我遇到过模型给了一个会触发全表扫描的参数,查询跑了三分钟还没回来。后来我给每个动作加了超时装饰器,超过阈值直接中断,返回Observation: 动作执行超时。模型看到超时提示,通常会换个参数重试。
异常兜底也很关键。动作函数内部抛异常,不能让整个 Agent 崩掉。我统一用 try/except 包住,把异常信息转成 Observation 返回。这样即使某个动作有 bug,Agent 也能继续跑,只是那一步失败而已。
6. 完整循环:从用户输入到最终答复
6.1 主循环代码骨架
把上面几层串起来,主循环大概长这样:
def run_agent(user_input, max_steps=10): context = build_initial_context(user_input) for step in range(max_steps): # 1. 调用模型 raw_output = call_model(context) # 2. 解析输出 parsed = parse_model_output(raw_output) # 3. 检查是否终止 if parsed["final"]: return parsed["final"] # 4. 校验动作 action_name = parsed["action"] if action_name not in ACTIONS: observation = f"错误:动作 {action_name} 不存在" else: # 5. 解析并校验参数 try: params = json.loads(parsed["action_input"]) validate_params(action_name, params) # 6. 执行动作 result = execute_with_timeout(ACTIONS[action_name]["func"], params) observation = json.dumps(result, ensure_ascii=False) except Exception as e: observation = f"错误:{str(e)}" # 7. 把观察结果拼回上下文 context = append_observation(context, raw_output, observation) return "达到最大步数限制,任务未完成"这个骨架不到 40 行,但涵盖了整套逻辑。max_steps是必须的,防止模型陷入死循环。我一般设 10 到 15 步,复杂任务可以放宽。
6.2 上下文管理:别让 token 爆掉
多轮循环后上下文会越来越长。热词里有prompt token,这是真痛点。我的处理策略是:
- 保留最近 N 轮完整对话,N 一般取 5;
- 更早的轮次只保留 Observation 的摘要,不保留完整 Thought;
- 系统 Prompt 永远置顶,不参与裁剪。
摘要怎么做?简单点就直接截断,保留前 200 字符。讲究点可以让模型自己总结,但那又增加一次调用。我实测下来,对于大多数任务,直接截断足够用,因为早期的推理细节对后续步骤影响不大。
6.3 一个完整的执行示例
假设用户说"帮我看看上周订单情况,如果超过 1000 单就提醒我"。
第一轮模型输出:
Thought: 需要先查询上周订单数 Action: query_data ActionInput: {"table": "orders", "metric": "count", "date_range": "last_7_days"}代码执行返回{"result": 1234},拼回上下文。
第二轮模型输出:
Thought: 上周订单 1234 单,超过 1000,需要提醒用户 Final Answer: 上周订单共 1234 单,已超过 1000 单阈值,建议关注。整个流程两轮结束。你可以看到,模型全程只输出文本,所有真实操作都是代码执行的。这就是无 Tool Calling 方案的核心优势——可控。
7. 常见问题与排查实录
7.1 模型不按格式输出怎么办
这是最高频的问题。表现是模型输出一大段自然语言,完全没有 Thought/Action 标记。我的排查顺序是:
- 检查 Prompt 里的示例是否完整,模型很依赖示例;
- 检查是否给了模型"自由发挥"的空间,比如描述里写了"你可以灵活处理"这种话,删掉;
- 降低 temperature,我一般设 0.1 到 0.3;
- 如果还不行,在 Prompt 末尾加一句强约束:"如果你不按格式输出,系统将无法解析,任务会失败。"
实测下来,这四步能解决 95% 的格式问题。
7.2 模型反复调用同一个动作
有时候模型会陷入循环,一直调同一个动作,参数都不变。这通常是因为 Observation 没有给它足够的新信息。我的处理是:在 Observation 里加一个"已执行次数"标记,比如Observation (第 2 次调用 query_data): {...}。模型看到次数增加,通常会意识到该换策略了。另外,主循环里可以检测重复动作,连续三次相同就强制中断并提示模型。
7.3 动作参数总是差一点
模型给的参数经常"差不多但不对",比如日期格式差一位、枚举值拼错。我的经验是:在动作描述里把参数格式写死。不要写"date_range 是日期范围",要写"date_range 必须是以下之一:last_7_days, last_30_days, this_month"。把可选值列全,模型照抄的概率大幅提升。
7.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 输出无格式标记 | Prompt 示例不足或约束太弱 | 补示例、加负面约束、降 temperature |
| 动作名不存在 | 模型幻觉或动作描述不清 | 返回可用动作列表让它重试 |
| JSON 解析失败 | 模型输出脏 JSON | 分级容错 + 重试提示 |
| 陷入循环 | Observation 信息不足 | 加执行次数标记、强制中断 |
| token 超限 | 上下文未裁剪 | 保留近 N 轮、早期摘要化 |
| 动作执行超时 | 参数触发慢查询 | 加超时装饰器、参数白名单 |
7.5 几个我踩过的坑
坑一:以为模型会记住格式。不会的。每一轮都要把格式约定完整带上,别指望它从历史里学。
坑二:ActionInput 用多行 JSON。模型偶尔会为了"好看"把 JSON 格式化多行,解析器直接挂。后来我在 Prompt 里明确写"单行 JSON",并在解析器里做了多行拼接容错。
坑三:忘了限制 max_steps。有一次测试一个边界任务,模型循环了 200 多轮,token 账单直接爆了。从那以后 max_steps 是硬性配置。
坑四:动作描述写得太业务化。早期我把动作描述写得很具体,结果换个业务场景就得重写。后来改成抽象描述,比如"查询数据表"而不是"查询订单表",通用性好了很多。
8. 这套方案的边界与扩展方向
8.1 什么时候该换回 Tool Calling
我不想把这套方案吹成万能。如果你的 Agent 需要动态发现和调用大量未知工具,或者工具的参数结构极其复杂(嵌套好几层),那 Tool Calling 的原生支持确实更省事。无 Tool Calling 方案的优势在于可控和通用,代价是你需要自己维护解析和校验逻辑。任务越结构化,这套方案越香;任务越开放,Tool Calling 越合适。
8.2 可以往哪些方向扩展
几个我试过或正在试的方向:
- 多动作并行:让模型一次输出多个 Action,代码并行执行,适合无依赖的批量任务;
- 动作组合:把常用动作序列封装成"宏动作",模型调一个宏等于调一串;
- 自省机制:让模型在 Final Answer 前先输出一段"我是否遗漏了什么"的自检;
- 本地小模型:这套方案对模型能力要求不高,我试过用 7B 级别的本地模型跑,简单任务完全够用。
8.3 关于"harness 和 agent 区别"的一点看法
热词里有人问 harness 和 agent 的区别。我的理解是:harness 是"外壳",负责调度、解析、执行、容错;agent 是"内核",负责推理和决策。这套无 Tool Calling 方案里,我写的所有代码都是 harness,模型才是 agent。把这两者分清楚,架构就不会乱。很多人做 Agent 做得痛苦,就是因为把 harness 的活和 agent 的活混在一起,出了问题不知道怪谁。
我个人在实际项目里的体会是,Agent 的稳定性从来不取决于模型多聪明,而取决于 harness 多严谨。模型再强,输出格式飘一次,整个流程就断。反过来,harness 做得扎实,哪怕用中等能力的模型,任务也能稳稳跑完。这套无 Tool Calling 的结构化方案,本质上就是把功夫下在 harness 上——用确定性的代码,去兜住不确定性的模型。你要是也在做 Agent,不妨先别急着接 Tool Calling,用纯 Prompt 加解析器跑一版,你会发现很多问题其实在架构层面就能解决,根本不用求模型。