news 2026/10/5 5:18:51

不依赖Function Call:纯Prompt构建通用Agent实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
不依赖Function Call:纯Prompt构建通用Agent实战

1. 为什么我要绕开 Function Call 做 Agent

先说结论:Function Call 不是 Agent 的必需品,它只是一个"让模型输出结构化意图"的便捷通道。当你手上只有通用对话模型、或者模型厂商的 Function Call 接口不稳定、或者你压根不想被某一家 SDK 绑死的时候,用纯 Prompt 把结构化输出"逼"出来,是完全可行的,而且我在几个项目里跑下来,稳定性比想象中好得多。

这个实践的核心目标很明确:做一个不依赖任何原生 Function Call 能力、纯靠 Prompt 约束就能完成"思考—决策—调用工具—观察结果—继续推理"闭环的通用 Agent。它要能跑在任何支持文本补全或对话的模型上,工具调用格式由我自己定义,解析逻辑由我自己掌控。适合谁看?适合已经写过一两个玩具 Agent、被 Function Call 的兼容性问题折磨过、想搞清楚 Agent 底层到底在干什么的人。如果你连 ReAct 是什么都还没概念,建议先补一下 ReAct 的基本思路再回来,不然中间那几段推理循环的拆解会有点吃力。

我之所以走上这条路,起因很朴素:早期做 Agent 时,我用的模型 Function Call 返回格式在不同版本间反复横跳,今天arguments是字符串,明天变成对象,后天某个字段直接消失。更麻烦的是,一旦我想换模型,整套工具调用的胶水代码就得重写。后来我干脆把 Function Call 整个拿掉,改成让模型按我规定的文本格式输出"我要调用哪个工具、参数是什么",然后我自己写解析器。结果发现,只要 Prompt 设计得当,模型对格式的遵守程度相当高,而且这套逻辑可以无缝迁移到任何模型上。

这篇文章我会把整套设计拆开讲:Prompt 怎么设计才能让模型稳定吐结构化内容、解析器怎么写才能容错、ReAct 循环怎么组织、工具注册怎么做、并发和上下文怎么管、以及我踩过的那些坑。全程 Python 实现,代码可以直接抄。

2. 结构化输出的本质:让模型"填表"而不是"聊天"

2.1 Function Call 到底帮我们做了什么

很多人把 Function Call 当成某种魔法,其实它做的事情非常朴素:约束模型的输出空间,让它必须产出一个符合预定义 schema 的 JSON。模型厂商在训练和对齐阶段,专门强化了模型对工具定义的理解能力,所以当你传入工具列表时,模型知道该在什么时候输出一个结构化的调用请求,而不是继续闲聊。

换句话说,Function Call = 结构化输出约束 + 模型侧的专项对齐。第一部分我们自己能用 Prompt 做,第二部分我们做不了,但可以用"格式示例 + 强约束 + 解析容错"来逼近。

理解这一点很关键,因为它决定了我们绕开 Function Call 之后的策略:我们不是要复制 Function Call 的全部能力,而是要在一个更宽松的约束下,用工程手段把不确定性兜住。

2.2 用 Prompt 定义一套"工具调用协议"

我的做法是自定义一套极简的文本协议。模型每次需要调用工具时,必须输出一个特定标记包裹的 JSON 块,比如:

<tool_call> {"name": "search", "arguments": {"query": "python 邻接矩阵"}} </tool_call>

不需要调用工具、只想给出最终答案时,输出:

<final_answer> 这里是给用户的最终回复 </final_answer>

这套协议的好处是:标记清晰、易于正则匹配、JSON 部分可以独立解析、和自然语言混排也不会误伤。相比让模型直接输出裸 JSON,加了标记之后解析成功率明显提升,因为模型不容易把解释性文字和结构化内容混在一起。

Prompt 里我会明确写清楚三件事:第一,什么时候该调用工具(需要外部信息、需要计算、需要执行动作时);第二,调用格式长什么样(给出完整示例);第三,一次只能输出一个工具调用,调用后必须等待观察结果再继续。这三条约束缺一不可,尤其是第三条,否则模型会一口气编造多个工具调用的结果,直接跑飞。

2.3 为什么不用裸 JSON 或 XML

我试过三种格式:裸 JSON、XML、自定义标记 + JSON。实测下来:

格式解析成功率容错难度模型友好度
裸 JSON中高(容易和正文混淆)中
纯 XML中高中中
标记 + JSON高低高

裸 JSON 最大的问题是模型经常在 JSON 前后加解释,比如"好的,我来调用工具:{...}",解析时得先剥离前缀。XML 的问题是嵌套深了模型容易漏闭合标签。标记 + JSON 的组合,本质上是给模型一个明确的"起止信号",它只需要专注填中间的内容,认知负担最小,所以稳定性最好。

提示:标记名不要用太通用的词,比如<call>这种,容易和正文里的尖括号冲突。用<tool_call>这种带下划线的组合词,冲突概率低很多。

3. 解析器:整个 Agent 最容易被低估的部件

3.1 解析器的职责边界

很多人写 Agent 时把解析器当成一个json.loads就完事,这是大坑。解析器真正要处理的是模型输出的所有不完美情况:JSON 里有多余逗号、字符串没转义、标记只出现了一半、参数类型和预期不符、甚至模型把两个工具调用粘在一起。

我的解析器分三层:第一层用正则提取标记块,第二层对块内内容做 JSON 修复和解析,第三层做 schema 校验和类型转换。任何一层失败,都不直接抛异常终止,而是把错误信息作为"观察结果"喂回给模型,让它自己修正。这一点是纯 Prompt Agent 相比 Function Call 的最大优势:错误可以变成对话的一部分,而不是一个崩溃的异常。

3.2 容错解析的代码实现

import re import json TOOL_CALL_PATTERN = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL) FINAL_ANSWER_PATTERN = re.compile(r"<final_answer>(.*?)</final_answer>", re.DOTALL) def extract_tool_call(text): match = TOOL_CALL_PATTERN.search(text) if not match: return None raw = match.group(1).strip() return parse_json_lenient(raw) def parse_json_lenient(raw): try: return json.loads(raw) except json.JSONDecodeError: pass # 尝试修复常见问题:尾随逗号、单引号 fixed = re.sub(r",\s*([}\]])", r"\1", raw) fixed = fixed.replace("'", '"') try: return json.loads(fixed) except json.JSONDecodeError as e: return {"__parse_error__": str(e), "__raw__": raw}

这段代码的关键在于parse_json_lenient:先尝试标准解析,失败后做两步常见修复——去掉尾随逗号、把单引号换成双引号。这两步能救回大概八成的格式错误。剩下的救不回来的,就把原始内容和错误信息打包返回,交给上层决定怎么处理。

3.3 把解析失败变成模型的自我修正机会

解析失败时,我不会重试整个请求,而是构造一条观察消息:

观察结果:你的上一次工具调用格式有误,错误信息:Expecting ',' delimiter。 原始内容:{"name": "search" "arguments": {...}} 请重新输出符合格式的工具调用。

模型看到这条消息,绝大多数情况下能自己改对。这比在代码里硬编码各种修复规则要优雅得多,因为修复逻辑交给了模型本身。我实测过,第一次解析失败后,第二次修正的成功率在 90% 以上。

注意:不要把解析失败的原始内容原样喂回去太多次,否则模型可能陷入"复读错误"的循环。我一般设置最多两次修正机会,两次还不行就降级为直接返回文本答案。

4. ReAct 循环的骨架:思考、行动、观察怎么串

4.1 循环的四个阶段

一个完整的 Agent 循环,我拆成四步:构造 Prompt → 模型生成 → 解析输出 → 执行工具并回填观察。这四步循环往复,直到模型输出<final_answer>或者达到最大轮数。

这里有个容易忽略的细节:每一轮都要把历史消息完整带上,包括之前的思考、工具调用、观察结果。因为模型没有记忆,它判断下一步该干什么,完全依赖上下文里能看到的信息。如果你只带最后一条观察,模型会丢失任务目标,开始瞎猜。

4.2 消息历史的组织方式

我用一个列表存消息,每条消息是{"role": ..., "content": ...}。角色有三种:system(放工具定义和协议说明)、user(放任务)、assistant(放模型输出)、tool(放工具执行结果)。有些模型不支持tool角色,那就统一用user角色,在内容前加"观察结果:"前缀,效果一样。

messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, ] for step in range(MAX_STEPS): response = call_model(messages) messages.append({"role": "assistant", "content": response}) tool_call = extract_tool_call(response) if tool_call: result = execute_tool(tool_call) messages.append({"role": "user", "content": f"观察结果:{result}"}) continue final = extract_final_answer(response) if final: return final # 既没有工具调用也没有最终答案,提示模型 messages.append({"role": "user", "content": "请输出工具调用或最终答案。"})

这个骨架非常朴素,但它是所有 Agent 的地基。复杂的 Agent 无非是在这四步上做增强:加规划、加反思、加多工具并行、加记忆检索。地基不稳,上面堆再多花活都是空中楼阁。

4.3 最大轮数和死循环防护

一定要设MAX_STEPS,我一般设 10 到 15。超过就强制终止,返回当前最好的结果或者报错。没有这个上限,模型可能陷入"调用工具—观察—再调用同一个工具"的死循环,尤其是当工具返回的结果不满足它的预期时。

除了轮数上限,我还会检测重复调用:如果连续两轮调用了同一个工具、参数也几乎一样,就注入一条提示"你已经调用过这个工具,结果是 X,请基于此继续或换一个思路"。这一招能救回不少卡死的任务。

5. 工具注册:让 Agent 知道自己有什么武器

5.1 工具描述怎么写模型才看得懂

工具注册的核心不是代码,是描述文本。模型判断该不该调用某个工具,完全依赖你给的描述。描述写得好,调用准确率翻倍;写得含糊,模型要么不用,要么乱用。

我的工具描述模板包含四部分:名称、功能一句话、参数说明、使用场景。比如:

工具名:search 功能:在互联网上搜索信息 参数: - query (string, 必填):搜索关键词 使用场景:当你需要获取实时信息、事实核查、或知识库之外的内容时使用

"使用场景"这一条最容易被省略,但它恰恰是模型决策的关键。告诉模型"什么时候用",比告诉它"能做什么"更重要。

5.2 工具注册表的代码结构

class ToolRegistry: def __init__(self): self.tools = {} def register(self, name, func, description, schema): self.tools[name] = { "func": func, "description": description, "schema": schema, } def describe_all(self): lines = [] for name, t in self.tools.items(): lines.append(f"工具名:{name}\n功能:{t['description']}\n参数:{t['schema']}") return "\n\n".join(lines) def execute(self, name, arguments): if name not in self.tools: return f"错误:不存在名为 {name} 的工具" try: return self.tools[name]["func"](**arguments) except Exception as e: return f"工具执行出错:{e}"

describe_all的输出直接拼进 System Prompt,模型就能看到所有可用工具。execute里对未知工具和异常都做了兜底,返回错误字符串而不是抛异常,这样错误能作为观察结果回传给模型。

5.3 参数校验不能省

模型给的参数经常有类型问题,比如该传整数传了字符串、该传列表传了单个值。我在execute之前加一层轻量校验,根据 schema 做类型转换和必填检查。校验失败不要直接报错终止,而是返回一条清晰的提示,比如"参数 query 是必填项,你漏了",模型看到后通常能补上。

6. 上下文管理:Agent 跑久了会"失忆"和"爆窗"

6.1 上下文膨胀的真实速度

一个稍微复杂的任务,跑个七八轮,每轮的工具返回可能几百上千字,上下文轻松破万 token。如果不管理,要么撞上模型的上下文上限,要么成本飙升。我做过统计,一个中等复杂度的调研任务,不做任何裁剪的话,平均消耗是必要信息的 3 到 5 倍。

6.2 三种裁剪策略

我的做法是分层裁剪:

  • 工具结果截断:单个工具返回超过 2000 字符的,只保留前 1500 和后 300,中间用省略号。绝大多数任务不需要完整的长文本。
  • 历史轮次压缩:超过 N 轮之后,把早期的"思考 + 工具调用 + 观察"压缩成一句摘要,比如"第 1-3 轮:搜索了 X,得到了 Y 的结论"。
  • 关键信息锚定:把任务目标、已确认的关键事实单独存一份,每轮都带上,防止被裁剪掉。

这三种策略组合使用,能把上下文控制在合理范围内,同时不丢失关键信息。

6.3 别让裁剪破坏推理链

裁剪有个大坑:如果把模型上一步的思考裁掉了,它下一步的推理会断裂。所以裁剪时,最近两轮的完整内容必须保留,只裁更早的。这个边界要卡准,我一般保留最近 2 轮完整、更早的做摘要。

提示:摘要不要用另一个模型去生成,成本高且慢。用规则化的模板拼一句话就够了,比如"第 X 轮调用了工具 Y,参数 Z,返回结果摘要:..."。

7. 我踩过的坑和对应的解法

7.1 模型"假装"调用了工具

最开始的版本,模型经常在正文里写"我将调用 search 工具搜索...",但根本不输出<tool_call>标记。原因是我的 Prompt 里对格式的强调不够强。解法是在 System Prompt 里用加粗 + 重复 + 反例三重强调:明确说"不要用自然语言描述你要调用工具,必须输出标记块",并给一个错误示例和一个正确示例对照。

7.2 参数里塞了多余的解释

模型有时会在 JSON 参数里加注释,比如{"query": "python 教程" // 搜索关键词}。这在标准 JSON 里是非法的,直接解析失败。我的解法是在parse_json_lenient里加一步:用正则去掉//到行尾的内容。这个修复规则救回了不少 case。

7.3 工具返回太长导致模型"读不完"

有一次工具返回了一篇长文,模型在下一轮直接忽略了内容,继续调用别的工具。原因是长文本淹没了任务目标。解法就是上面说的截断策略,同时把任务目标在每轮末尾再强调一次。

7.4 并发场景下的状态污染

如果你要同时跑多个 Agent 实例,千万别用全局变量存消息历史。我早期图省事用了一个模块级列表,结果两个任务互相串消息,输出完全乱套。后来改成每个任务一个独立的AgentSession对象,所有状态都挂在实例上,问题消失。

坑现象解法
假装调用工具正文描述但不输出标记Prompt 强约束 + 正反例
参数带注释JSON 解析失败正则去注释
长返回淹没目标模型忽略观察结果截断 + 目标重述
全局状态污染多任务串消息实例化 Session

8. 并发与性能:Agent 怎么扛住多任务

8.1 瓶颈到底在哪

Agent 的性能瓶颈几乎永远在模型调用上,不在你的 Python 代码。一次模型调用几百毫秒到几秒不等,工具执行通常快得多。所以优化方向很明确:减少模型调用次数、并行化独立的模型调用、缓存可复用的结果。

8.2 用异步并发跑多任务

如果每个任务之间独立,用asyncio并发跑是最直接的。把模型调用和工具执行都写成 async 函数,用asyncio.gather批量执行。我实测过,10 个独立任务串行跑要 30 秒,并发跑只要 5 秒左右,提升非常明显。

import asyncio async def run_agent(task): session = AgentSession() return await session.run(task) async def main(tasks): results = await asyncio.gather(*[run_agent(t) for t in tasks]) return results

注意模型 API 一般有并发限制,别一次性发太多,加个信号量控制并发数。

8.3 单任务内部的并行

有些任务里,模型会连续调用几个互不依赖的工具。这种情况可以在解析出多个工具调用时并行执行。但前提是你的 Prompt 允许模型一次输出多个调用,这又增加了格式复杂度。我的建议是:先做单调用,稳定之后再考虑多调用并行,别一上来就追求极致。

9. 写在最后的一点个人体会

这套无 Function Call 的结构化 Agent,我从第一版到现在迭代了大概五六次,最大的感受是:Agent 的难点从来不在"调用工具"这个动作本身,而在于如何让模型在长链条推理中保持目标不漂移、格式不崩坏、错误能自愈。Function Call 帮你解决了格式问题,但解决不了目标漂移和错误自愈,这些还是得靠 Prompt 设计和循环控制来做。

我现在更倾向于把这套纯 Prompt 方案当成"理解 Agent 本质"的训练场。当你能用最朴素的文本协议把 Agent 跑通,再回头看那些封装好的框架,你会发现它们做的事情你全都懂,用起来也更知道哪里可能出问题。至于生产环境用不用 Function Call,那是另一个维度的取舍——要极致稳定就用原生能力,要极致灵活和可迁移就用纯 Prompt,两者并不矛盾,甚至可以混用:关键工具走 Function Call,边缘工具走文本协议。

最后分享一个小技巧:调试 Agent 时,把每一轮的完整消息历史打印出来,用分隔线隔开。你会非常直观地看到模型在哪一步开始跑偏,比看最终输出有用得多。这个习惯帮我定位了至少一半的诡异 bug。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 5:18:35

红外遥控NEC协议全解析:从载波调制到解码实战

按下遥控器&#xff0c;电视亮起来。这个动作我从小到大做了上万次&#xff0c;直到某天我把逻辑分析仪的探头接到红外接收头的输出脚上&#xff0c;按下按键&#xff0c;看到屏幕上跳出的一串波形&#xff0c;才发现自己一直以为的“红外线”根本不是一束简单的光&#xff0c;…

作者头像 李华
网站建设 2026/10/5 5:18:35

UVCCamera stopPreview崩溃:Native层SIGSEGV与生命周期修复实践

先说结论&#xff1a;如果你在用 saki4510t 的 UVCCamera 库做 USB 摄像头预览&#xff0c;遇到了 startPreview 正常、一调 stopPreview 就崩溃闪退的情况&#xff0c;九成不是 Java 代码写错&#xff0c;而是 UVC 底层释放流程和你 Activity/Surface 的生命周期在抢时间。上周…

作者头像 李华
网站建设 2026/10/5 5:17:02

Slack+Cloudflare Workers+MCP:构建主动式AI Agent实战

1. 从一条 GitHub 热榜说起&#xff1a;company-brain 到底想解决什么问题第一次在 GitHub 趋势榜上刷到company-brain这个项目时&#xff0c;我的第一反应是&#xff1a;终于有人把“AI 主动干活”这件事从演示视频里拽出来&#xff0c;塞进了团队每天真正在用的工具里。它的定…

作者头像 李华
网站建设 2026/10/5 5:16:59

AI Agent工具调用治理实战:Dogwood如何为Agent立规矩

很多做 AI Agent 平台的朋友&#xff0c;一开始都被“工具调用”这四个字坑惨了。模型今天想调天气接口&#xff0c;明天想读写数据库&#xff0c;后天可能把删除接口当成查询接口用——你根本不知道它会怎么使唤这些工具。我搭过几套内部 Agent 平台&#xff0c;最大的体会是&…

作者头像 李华
网站建设 2026/10/5 5:15:40

安卓手机变身STM32调试上位机:USB串口通讯实战指南

把安卓手机当成STM32的调试上位机&#xff0c;这事儿听起来挺折腾&#xff0c;但实际做下来会发现&#xff0c;它比想象中简单&#xff0c;而且非常实用。尤其当你做便携式设备、野外调试&#xff0c;或者不想抱着笔记本跑来跑去的时候&#xff0c;手机通过USB串口直接跟单片机…

作者头像 李华
网站建设 2026/10/5 5:15:07

树莓派离线唤醒词引擎Snowboy安装实战与调试指南

我最初在树莓派上折腾Snowboy&#xff0c;是想给一个老旧的USB麦克风找个正经用途。树莓派装Snowboy&#xff0c;说到底就是在本地跑一个“离线唤醒词检测引擎”&#xff0c;让树莓派像智能音箱一样&#xff0c;听到特定词才响应&#xff0c;而不是连续录音上传到云端。这个项目…

作者头像 李华