ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

不依赖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(rtool_call(.*?)/tool_call, re.DOTALL) FINAL_ANSWER_PATTERN re.compile(rfinal_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 解析失败正则去注释长返回淹没目标模型忽略观察结果截断 目标重述全局状态污染多任务串消息实例化 Session8. 并发与性能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。
返回列表