1. 为什么我要绕开 Function Call 做 Agent
1.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 为什么选文本协议而不是 JSON
Function 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"各种变体。所以标签匹配不能用精确匹配,得用正则。
我的正则大概长这样(Python):
import 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, head=1500, tail=500): 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 的行为更可预测,也更容易调试。我加了之后,死循环和乱调工具的情况明显减少。
这个框架我还在持续迭代,后面有新发现再分享。如果你也在做类似的东西,欢迎交流踩坑经验。