☰
无Tool Calling的结构化通用Agent:用Prompt约束与代码执行构建稳定智能体
2026/10/28 5:39:57 网站建设 项目流程

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/ActionPrompt 模板、模型客户端
解析层解析模型输出,校验格式正则解析器、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 后面加个句号。解析器必须足够宽容,但校验必须足够严格。我的解析流程是:

  1. 按行扫描,找到以Thought:、Action:、ActionInput:、Final Answer:开头的行;
  2. 提取每段的内容,去掉首尾空白;
  3. 如果发现Final Answer,直接返回终止信号;
  4. 否则校验 Action 是否在注册表中,ActionInput 是否能解析成 JSON;
  5. 任何一步失败,返回一个格式错误提示,让模型重试。

这里有个关键设计:解析失败不抛异常,而是作为 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 标记。我的排查顺序是:

  1. 检查 Prompt 里的示例是否完整,模型很依赖示例;
  2. 检查是否给了模型"自由发挥"的空间,比如描述里写了"你可以灵活处理"这种话,删掉;
  3. 降低 temperature,我一般设 0.1 到 0.3;
  4. 如果还不行,在 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 加解析器跑一版,你会发现很多问题其实在架构层面就能解决,根本不用求模型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询