告别 Function Call:纯 Prompt 工程构建跨模型通用 Agent 实战

发布时间:2026/10/5 9:23:34
告别 Function Call:纯 Prompt 工程构建跨模型通用 Agent 实战
1. 为什么我要绕开 Function Call 做 Agent1.1 一个被过度神化的接口过去一年只要聊到 Agent 开发几乎绕不开 Function Call 这个词。各家模型厂商把它当成卖点各种框架把它当成标配好像不接 Function Call 就不配叫 Agent。我自己一开始也是这么想的直到在一个真实项目里被它反复教育。那个项目需要 Agent 调用十几个内部工具涉及数据库查询、文件读写、外部接口请求。用 Function Call 写第一版的时候确实很爽模型直接吐出一个结构化的 JSON我解析一下就能执行。但问题很快来了换模型就崩。A 模型支持的 schema 格式B 模型不认C 模型对嵌套参数的处理和文档写的不一样D 模型干脆在参数里塞了注释导致 JSON 解析失败。更麻烦的是有些模型在 Function Call 模式下会偷懒明明该调工具它直接编一个看起来合理的答案糊弄过去。那段时间我每天的工作就是给不同模型写适配层代码里全是 if model xxx 的分支。后来我停下来想了一个问题Function Call 到底解决了什么它解决的是让模型输出结构化数据这件事。但这件事真的只能靠 Function Call 吗1.2 结构化输出的本质是什么把问题拆到底层Agent 的核心循环其实就三步思考、行动、观察。思考是模型根据当前状态决定下一步做什么行动是执行某个工具观察是把工具结果喂回模型。Function Call 只是行动这一步的一种实现方式它让模型用特定的格式告诉外部我要调哪个工具、传什么参数。但告诉外部要调什么这件事本质上就是让模型输出一段可解析的文本。Function Call 是一种强约束的输出格式而 Prompt 约束是一种弱约束的输出格式。两者要达到的目的完全一样区别只在于约束由谁来保证——是模型厂商在推理层面保证还是你在 Prompt 层面保证。想通这一点之后我决定做一个实验完全不用 Function Call纯靠 Prompt 工程 文本解析能不能做出一个稳定可用的通用 Agent答案是能而且比我想象的稳。1.3 无 Function Call 方案的适用边界先说清楚我不是说 Function Call 没用。如果你的场景是单一模型、工具数量少、对延迟敏感Function Call 确实省事。但如果你符合下面任意一条无 Function Call 的方案值得认真考虑需要跨模型兼容今天用这家明天可能换那家不想被绑定工具数量多且经常变十几个甚至几十个工具schema 维护成本高需要精细控制推理过程想让模型显式地展示思考步骤而不是黑盒调用部署环境受限某些本地模型或自部署模型对 Function Call 支持不完整想降低 token 消耗Function Call 的 schema 描述本身就很占 token我做的这个 Agent 就是冲着通用两个字去的。所谓通用就是换模型不用改代码加工具不用改框架Prompt 一换就能跑。下面把整个设计和实现过程拆开讲。2. 整体架构设计把 Agent 拆成四个可替换的零件2.1 核心循环的重新定义传统 ReAct 的循环是 Thought → Action → Observation这个没问题我保留。但我要做的是把每个环节的实现方式都变成可替换的。整个 Agent 由四个零件组成零件职责可替换点Prompt 模板定义模型的行为规范换模型时改这里输出解析器从模型输出里提取行动指令换格式时改这里工具注册表管理可用工具及其描述加工具时改这里执行引擎调度循环、处理异常、控制轮次基本不用改这个拆法的好处是变化被隔离在局部。换模型只需要调 Prompt 模板和解析器加工具只需要动注册表核心循环稳如泰山。我见过太多项目把这几件事揉在一起改一处崩三处。2.2 为什么选文本协议而不是 JSONFunction Call 用的是 JSON因为 JSON 是机器友好的。但我要让模型输出模型对 JSON 的手感其实一般——它容易在字符串里漏引号、在嵌套对象里多逗号、在数字和字符串之间搞混。尤其是小模型JSON 输出错误率相当高。我选了一个更模型友好的文本协议格式长这样思考: 用户想知道今天的天气我需要调用天气查询工具 行动: get_weather 参数: {city: 北京, date: today}为什么这么设计三个原因。第一思考在前强制模型先想再做这是 ReAct 的精髓能显著降低乱调工具的概率。第二行动和参数分开行动是工具名参数是 JSON这样解析逻辑简单工具名用正则就能抓参数用 JSON 解析器处理。第三用中文标签因为我的 Prompt 是中文的模型在中文语境下对中文标签的跟随度更高实测比英文标签的格式错误率低不少。提示标签用什么语言取决于你的 Prompt 主语言。中英混用是格式错误的高发区尽量统一。2.3 工具描述的组织方式工具注册表里每个工具包含四样东西名称、功能描述、参数说明、调用示例。名称是英文的因为要作为解析锚点描述和参数说明是中文的因为要给模型看示例是最关键的模型对示例的模仿能力远超对规则的理解能力。我试过只写规则不写示例模型经常把参数格式搞错。后来每个工具都配一个完整的调用示例格式错误率直接降了一个数量级。这跟教新人一样你跟他讲十遍规范不如给他看一个做好的样板。2.4 循环控制的几个关键参数执行引擎里有几个参数需要仔细调最大轮次默认 10 轮。太少复杂任务做不完太多容易陷入死循环烧 token。10 轮是个经验值覆盖了绝大多数任务。单轮超时工具执行超过 30 秒就中断防止某个工具卡死拖垮整个 Agent。重复检测如果连续两轮调同一个工具传同样的参数直接中断并返回错误。这个机制救过我很多次模型有时候会卡在某个工具上反复调。观察截断工具返回结果超过 2000 字符就截断只保留头尾。长结果会挤爆上下文而且模型对超长文本的利用率很低。这些参数没有标准答案得根据你的任务特点调。我的建议是先给保守值跑一批真实任务看日志再逐步放宽或收紧。3. Prompt 工程让模型乖乖按格式输出3.1 Prompt 的骨架结构我的系统 Prompt 分五块顺序很重要角色定义一句话说清楚 Agent 是什么、要干什么能力边界明确告诉它有哪些工具、不能做什么输出格式规范用示例展示期望的输出格式行为准则什么时候该调工具、什么时候该直接回答异常处理工具报错怎么办、信息不足怎么办这个顺序不是随便排的。角色定义放最前面是因为它影响模型的整体基调格式规范放在能力边界之后是因为模型需要先知道有什么工具才能理解格式里的工具名是什么意思异常处理放最后作为兜底。3.2 格式规范怎么写才有效这是整个 Prompt 里最考验功夫的部分。我踩过的坑包括示例太简单导致模型遇到复杂情况就懵、规则太啰嗦导致模型抓不住重点、格式描述有歧义导致模型理解偏差。最后我总结出一个写法一个完整示例 三条硬规则 两个反例。完整示例展示标准格式硬规则强调不可违反的点反例展示常见错误。比如标准格式 思考: [你的推理过程] 行动: [工具名称] 参数: [JSON格式的参数] 硬规则 1. 每次输出必须包含思考和行动两行 2. 参数必须是合法的JSON字符串用双引号 3. 如果不需要调用工具行动写finish参数里放最终答案 反例不要这样 思考: 我要查天气 行动: get_weather 参数: city北京 ← 参数不是JSON 思考: 行动: get_weather 参数: {} ← 缺少思考内容反例这一招特别管用。模型看到不要这样的示例比看到十条要这样的规则印象更深。这跟人一样负面示例的警示效果往往更强。3.3 工具描述的写法技巧工具描述不是写给人类看的文档是写给模型看的使用说明书。区别在于人类能理解省略和隐含模型不能。所以工具描述要极度明确、极度具体。我总结的写法是三要素什么时候用、怎么用、用了会得到什么。举个例子工具名search_database 什么时候用当用户询问存储在数据库里的业务数据时使用比如订单、用户、库存 怎么用传入一个查询条件对象支持字段有 table表名、filter过滤条件、limit返回条数 用了会得到什么返回匹配的记录列表每条记录是一个对象注意什么时候用这一条它其实是在帮模型做决策。很多工具调用错误不是因为模型不会用而是因为它不知道该用。把使用场景写清楚能大幅降低误用率。3.4 处理模型不听话的几种策略再好的 Prompt 也有失效的时候。我准备了四层防御第一层格式纠错重试。如果解析失败把错误信息拼回 Prompt让模型重新输出一次。大多数格式错误一次重试就能解决。第二层降级解析。如果严格解析失败用宽松的正则去抓关键信息。比如 JSON 解析失败就尝试用正则提取键值对。这层能救回一部分格式不太对但内容对的输出。第三层强制格式。如果连续两次解析失败切换到只输出工具名和参数不要思考的简化模式。牺牲推理质量换格式正确。第四层人工兜底。如果还不行返回一个明确的错误让上层决定怎么处理。绝不静默失败那是最坑的。这四层下来我实测的格式成功率从最初的 70% 左右提到了 99% 以上。剩下那 1% 基本是模型本身抽风重试也没用只能兜底。4. 解析器实现从文本里精准抠出行动指令4.1 解析流程的分步设计解析器干的事就一件把模型输出的一段文本变成结构化的{thought, action, params}。听起来简单做起来全是细节。我的解析流程分五步预处理去掉首尾空白统一换行符处理全角半角混用分段按思考行动参数三个标签切分提取行动从行动段里抓工具名解析参数从参数段里解析 JSON校验检查工具名是否在注册表里、参数是否符合工具要求每一步都可能出问题所以每一步都要有明确的失败处理。4.2 标签匹配的容错处理模型输出标签的时候花样特别多。我见过思考:、思考、思考 :、思考:、思考\n各种变体。所以标签匹配不能用精确匹配得用正则。我的正则大概长这样Pythonimport re def extract_sections(text): # 匹配思考标签允许前后有空格、冒号全半角、markdown加粗 thought_pattern r[*\s]*思考[*\s]*[:]\s*(.*?)(?[*\s]*行动[*\s]*[:]|$) action_pattern r[*\s]*行动[*\s]*[:]\s*(.*?)(?[*\s]*参数[*\s]*[:]|$) params_pattern r[*\s]*参数[*\s]*[:]\s*(.*?)$ thought re.search(thought_pattern, text, re.DOTALL) action re.search(action_pattern, text, re.DOTALL) params re.search(params_pattern, text, re.DOTALL) return { thought: thought.group(1).strip() if thought else , action: action.group(1).strip() if action else , params: params.group(1).strip() if params else }关键点是re.DOTALL让.能匹配换行因为思考和参数经常是多行的。还有.*?用非贪婪匹配防止跨段抓取。4.3 JSON 参数的清洗与修复参数段是重灾区。模型输出的 JSON 常见问题有用了单引号、末尾多了逗号、字符串没加引号、中文标点混入、嵌套层级错误。我写了一个清洗函数按顺序处理这些问题import json import re def clean_and_parse_params(raw): if not raw or raw.strip() in (, {}, 无, None): return {} # 去掉markdown代码块标记 raw re.sub(r(?:json)?, , raw).strip() # 中文标点转英文 raw raw.replace(“, ).replace(”, ) raw raw.replace(‘, ).replace(’, ) raw raw.replace(, :).replace(, ,) # 尝试直接解析 try: return json.loads(raw) except json.JSONDecodeError: pass # 单引号转双引号简单场景 try: fixed re.sub(r([^]*), r\1, raw) return json.loads(fixed) except json.JSONDecodeError: pass # 去掉末尾逗号 try: fixed re.sub(r,\s*([}\]]), r\1, raw) return json.loads(fixed) except json.JSONDecodeError: pass # 最后兜底用正则提取键值对 result {} for match in re.finditer(r[\]?(\w)[\]?\s*[:]\s*[\]?([^,}\]\])[\]?, raw): result[match.group(1)] match.group(2).strip() return result if result else None这个函数是层层降级的能救回大部分差一点就对的 JSON。实测下来直接解析成功率大概 85%加上清洗能到 97%最后兜底再补 2%。4.4 工具名匹配的模糊策略模型有时候会把工具名写错比如大小写不对、多了空格、加了引号。我的处理是先精确匹配失败后做归一化再匹配def match_tool(action_text, tool_registry): # 精确匹配 if action_text in tool_registry: return action_text # 归一化去空格、转小写、去引号 normalized action_text.strip().strip(\).lower().replace( , _) for name in tool_registry: if name.lower() normalized: return name # 包含匹配谨慎使用 candidates [name for name in tool_registry if normalized in name.lower()] if len(candidates) 1: return candidates[0] return None包含匹配要谨慎因为可能误匹配。我加了只有一个候选才接受的限制多个候选就返回失败让上层重试。注意模糊匹配是双刃剑。匹配太松会把错误的工具名匹配到错误的工具上导致更隐蔽的 bug。宁可匹配失败重试也不要错误匹配。5. 工具注册与执行让加工具变成一件轻松事5.1 工具注册表的数据结构工具注册表我用一个字典管理key 是工具名value 是一个包含元信息和执行函数的对象class Tool: def __init__(self, name, description, param_schema, func, example): self.name name self.description description self.param_schema param_schema self.func func self.example example def to_prompt_text(self): return f工具名{self.name} 功能{self.description} 参数{self.param_schema} 示例{self.example} tool_registry {} def register_tool(tool): tool_registry[tool.name] toolto_prompt_text这个方法很关键它负责把工具元信息转成 Prompt 里能用的文本。这样加工具的时候只要注册进去Prompt 自动就更新了不用手动改 Prompt 模板。5.2 参数校验的必要性模型传的参数经常不符合预期。比如该传整数的传了字符串该传列表的传了单个值必填参数漏了。如果不校验直接执行轻则工具报错重则产生副作用。我写了一个简单的校验器根据param_schema检查参数def validate_params(params, schema): errors [] for field, spec in schema.items(): if spec.get(required) and field not in params: errors.append(f缺少必填参数{field}) continue if field in params: expected_type spec.get(type) actual params[field] if expected_type int and not isinstance(actual, int): try: params[field] int(actual) except (ValueError, TypeError): errors.append(f参数 {field} 应为整数实际为 {actual}) elif expected_type list and not isinstance(actual, list): params[field] [actual] return errors注意这里做了类型转换能转就转不能转才报错。模型传字符串5的时候转成整数 5 比直接报错更实用。5.3 执行引擎的异常处理工具执行可能抛各种异常网络超时、文件不存在、权限不足、参数错误。执行引擎要做的不是吞掉异常而是把异常转成模型能理解的观察结果喂回去让它决定下一步。def execute_tool(tool_name, params): tool tool_registry.get(tool_name) if not tool: return f错误工具 {tool_name} 不存在 errors validate_params(params, tool.param_schema) if errors: return f参数错误{; .join(errors)} try: result tool.func(**params) return str(result)[:2000] except TimeoutError: return 错误工具执行超时请检查参数或稍后重试 except Exception as e: return f错误{type(e).__name__}: {str(e)}把异常转成文本喂回模型模型看到参数错误就知道要改参数看到超时就知道要重试或换方法。这比直接崩溃友好太多。5.4 观察结果的截断策略工具返回的结果可能很长比如数据库查询返回几百条记录。全塞进上下文会挤爆 token而且模型对超长文本的利用率很低。我的截断策略是保留头部 1500 字符 尾部 500 字符中间用省略号。为什么头尾都留因为头部通常是结果的开始包含关键信息尾部可能有总结或错误信息。中间往往是重复的列表项截掉损失最小。def truncate_observation(text, head1500, tail500): if len(text) head tail: return text return text[:head] f\n...[省略 {len(text) - head - tail} 字符]...\n text[-tail:]这个策略是我试了好几种之后定下来的。只留头部会丢尾部信息只留尾部会丢上下文头尾都留效果最好。6. 实战踩坑记录与排查手册6.1 模型陷入死循环怎么办这是最常见的问题。模型调一个工具得到结果不满意再调一次还不满意再调……直到轮次耗尽。我遇到过最夸张的一次模型连续调了 8 次同一个搜索工具每次参数只差一个字。解决方案是重复检测 强制收敛。重复检测前面提过连续两轮同工具同参数就中断。强制收敛是在轮次过半还没结束时往 Prompt 里注入一条系统消息你已经进行了 N 轮请尽快给出最终答案或明确说明无法完成。if current_round max_rounds * 0.6: messages.append({ role: system, content: 注意轮次已过半请评估当前信息是否足够。如果足够请用 finish 行动给出答案如果不足请说明还缺什么。 })这条消息很管用模型看到之后通常会收敛。原理是它给了模型一个该收尾了的信号避免无限探索。6.2 参数解析失败的排查思路参数解析失败的时候别急着改代码先看原始输出。我习惯把每次解析失败的原始文本记到日志里攒一批之后分析规律。我发现的问题分布大概是这样的问题类型占比典型表现引号问题35%单引号、中文引号、漏引号逗号问题20%末尾多逗号、中文逗号嵌套问题15%对象嵌套层级错误类型问题15%数字写成字符串、布尔写成字符串其他15%代码块标记、注释混入针对占比最高的引号和逗号问题我在清洗函数里做了重点处理。嵌套问题比较难自动修复通常靠重试。类型问题靠校验器转换。6.3 工具选择错误的几种情况模型选错工具通常有三种原因第一种工具描述不清。两个工具功能相似模型分不清该用哪个。解决办法是在描述里明确区分场景比如查询单个用户用 get_user查询用户列表用 list_users。第二种缺少使用场景说明。模型不知道什么时候该用这个工具。解决办法是在描述里加什么时候用这一条。第三种Prompt 里的工具太多。超过 15 个工具之后模型的选择准确率明显下降。解决办法是分组或者用两阶段选择——先让模型选工具类别再在类别里选具体工具。我实测下来工具数量控制在 10 个以内选择准确率最高。超过 15 个就得考虑分组了。6.4 常见问题速查表现象可能原因排查方法解决方向解析一直失败格式规范不清看原始输出加强 Prompt 示例模型不调工具行为准则缺失看思考内容补充何时调工具说明调错工具描述有歧义对比工具描述明确区分使用场景死循环无重复检测看调用历史加重复检测和收敛提示参数总错示例不完整看参数格式每个工具配完整示例响应太慢轮次太多看轮次统计收紧最大轮次token 爆了观察太长看上下文长度加强观察截断6.5 几个反直觉的经验经验一模型越强Prompt 越要简单。我一开始用复杂 Prompt 适配所有模型结果强模型被复杂 Prompt 束缚了发挥。后来改成基础 Prompt 模型特定微调效果好很多。强模型需要的是清晰的边界不是详细的步骤。经验二示例比规则重要。我花了很多时间写规则后来发现给三个好示例比写三十条规则管用。模型是模仿者不是推理者。经验三失败重试比一次成功更划算。与其把 Prompt 调到 95% 一次成功率不如接受 85% 一次成功率 重试机制。后者总成本更低而且更鲁棒。经验四日志比调试器有用。Agent 的行为是概率性的调试器断点会改变时序。老老实实打日志攒够样本再分析比单步调试高效得多。7. 性能优化与扩展方向7.1 降低 token 消耗的几个手段无 Function Call 方案的一个隐性成本是 Prompt 变长了因为工具描述和格式规范都要写进 Prompt。我做了几件事来控制 token工具描述按需加载。不是所有工具每轮都要展示。我根据用户输入先做一次粗筛只把相关的工具描述放进 Prompt。粗筛用关键词匹配就行不需要模型参与。思考内容限长。Prompt 里明确要求思考不超过 100 字防止模型长篇大论。思考是给模型自己看的不需要写作文。历史消息压缩。超过 5 轮的对话把早期的观察结果压缩成摘要。摘要用规则生成比如已查询数据库返回 3 条记录。这几招下来平均 token 消耗降了大概 40%。7.2 并发场景下的注意事项Agent 扛并发是个真问题。我的方案里Agent 实例是无状态的所有状态都在单次请求的上下文里所以天然支持并发。但有几个坑要注意工具执行要加锁。如果工具有共享资源比如写同一个文件并发调用会出问题。我在工具层面加了锁或者把有副作用的工具改成队列执行。模型调用要限流。并发太高会被模型服务限流导致大量失败。我加了一个信号量控制并发数超出的请求排队。日志要带请求 ID。并发场景下日志会混在一起没有请求 ID 根本没法排查。每个请求生成一个 UUID所有日志都带上。7.3 后续可以扩展的方向这个 Agent 目前是个基础框架能跑通核心循环。后面我打算往几个方向扩展多 Agent 协作。单个 Agent 能力有限多个 Agent 分工协作能处理更复杂的任务。比如一个负责规划一个负责执行一个负责检查。记忆机制。目前每次请求都是无状态的加一个长期记忆能让 Agent 记住用户偏好和历史交互。工具自动发现。现在是手动注册工具未来可以扫描代码自动发现可用的工具函数减少维护成本。可视化调试。把 Agent 的思考过程可视化出来方便调试和演示。这个对排查问题特别有用。8. 一些个人体会做这个无 Function Call Agent 的过程最大的收获不是技术本身而是对约束这件事的理解。Function Call 是一种外部约束Prompt 是一种内部约束。外部约束省事但受限内部约束灵活但要自己兜底。选哪种取决于你要的是省事还是自由。我选自由是因为我的场景需要跨模型、需要频繁加工具、需要精细控制。如果你的场景不需要这些Function Call 依然是更省事的选择。技术选型没有对错只有合不合适。另外一点体会是Agent 开发里最难的从来不是模型调用而是异常处理。模型会抽风工具会报错参数会传错网络会超时。把这些异常都处理好Agent 才能稳定跑起来。我大概花了 60% 的时间在异常处理上这个比例我觉得是合理的。最后分享一个小技巧给 Agent 加一个自言自语模式。在思考里让它显式地评估我现在的信息够不够我下一步该做什么如果失败了我有什么备选方案。这个模式能让 Agent 的行为更可预测也更容易调试。我加了之后死循环和乱调工具的情况明显减少。这个框架我还在持续迭代后面有新发现再分享。如果你也在做类似的东西欢迎交流踩坑经验。