上个月我在做一款带工具的对话助手,核心需求是让模型在需要时能调用天气查询、日程管理这类外部能力。最开始建模的时候,我用了最省事的方案:在系统提示词里把JSON格式写得明明白白,让模型自己输出动作和参数,然后在代码里解析。结果整个联调阶段都在填自然语言解析的坑——模型有时把JSON包在Markdown代码块里,有时在JSON前面多加一句"好的,我来为您查询",有时把参数名从双引号改成单引号,最离谱的一次它把整个JSON对象的键值对顺序全打乱了,我的正则匹配直接瘫痪。后来换成模型接口原生支持的Function Calling机制,才真正体会到"专业的事要交给专业的机制去做"这句话的分量。这篇文章想以一个踩坑者而不是理论家的身份,把Function Calling从表层API调用到系统集成再到工程化落地完整拆一遍,顺带聊聊标题里那个听起来有点唬人的概念:AI Agent Harness Engineering——按我的理解,它就是那个把模型大脑和外部工具手脚衔接起来的“传动系统”。1. 从一次翻车的JSON手写解析,透视Function Calling的本质1.1 没有Function Calling的时代,Prompt输出JSON有多不靠谱先还原一下我最初的做法。面对“北京今天需要带伞吗”这种问题,我会在系统提示词里写:如果需要查询天气,请输出以下JSON格式: {"tool": "get_weather", "params": {"city": "北京"}} 不要输出其他内容。这段提示词在Demo场景下能跑通,但真实请求一多就原形毕露。模型毕竟是语言模型,它终归倾向于“说人话”,而不是做严格的格式输出。我实际遇到的典型情况有这么几类:在JSON外层包Markdown代码块,需要先用正则剥离输出里混入解释性文字,例如“根据您的需求,我查询了天气信息:”键名被改写,比如把params变成了parameters单引号替换双引号,null被写成None,布尔值写成字符串每一类我都得写对应的解析逻辑和重试策略。最让人崩溃的是,模型升级之后行为还会变——以前不包代码块的,升级后突然开始包了。你的解析代码永远在追赶模型的行为变化。1.2 Function Calling接口的真实工作方式功能调用机制和上述做法有本质区别:它不是要求模型在自然语言文本里“附带”一段JSON,而是让模型在解码阶段就被引导去生成一个结构化的工具调用请求。这个请求会作为一个独立的、带有固定字段的消息返回,不掺杂任何自然语言修饰。以常见的接口格式为例,模型返回的消息里会多出一个tool_calls数组,每个元素大概长这样:{ "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } }注意arguments依旧是一个JSON字符串,需要你自己解析。但它和Prompt手写JSON有本质区别:模型在arguments里生成的内容更加规范,不会出现“好的,我来查询”这种废话,也不会把双引号换成单引号。原因在于接口层在训练和推理时都针对结构化输出做了对齐,模型已经形成了“这个位置就应该输出合法JSON”的条件反射。如果你以为这个机制是万无一失的,那又掉进另一个坑了。实际体验下来,它只是把“从自然语言里猜JSON”的问题率降到了可接受范围,而不是降到了零。参数值幻觉、调用不存在的工具名、嵌套对象残缺,这些问题依然存在,我在第5章会专门展开。可以把三种方案做个直观对比:方案输出稳定性可控性解析成本适用场景Prompt手写JSON极差,随模型版本波动低高,需要大量正则和重试轻量体验、内部原型JSON Mode / Output Schema较好,但只保证格式中中,仍需二次校验需要纯结构化输出、不涉及工具选择Function Calling好,工具调用是原生能力高,可选指定工具低,字段固定Agent系统、多工具场景、复杂任务2. Harness Engineering:模型、工具与执行循环如何拧成一股绳2.1 “手脚”之外,更重要的是传动系统Function Calling本身只是给模型开了一扇“可以调用工具”的门。但要让Agent真正稳定地完成一连串任务,你需要围绕这个功能搭建一整套工程机制,这套机制就是标题里说的Harness Engineering。打个比方:LLM是“大脑”,负责理解意图和做决策;工具是“手和脚”,负责触碰外部世界;而Harness Engineering是“神经和骨骼系统”,负责把大脑的命令准确无误地传导到手脚,再把手脚感知到的信息传回大脑。没有这套传动系统,大脑再聪明,手脚也是瘫着的。一个最简Agent系统的循环长这样:用户输入 → 系统提示词 + 工具定义列表 → 模型模型推理,产出结论或调用请求如果有调用请求 → 代码解析请求 → 执行对应工具函数 → 拿到结果把工具结果作为新消息回填给模型模型收到结果后继续推理,可能再发起新一轮调用,直到给出最终答复这个看似简单的循环里,每一步都有陷阱:工具结果应该以什么角色消息回传?tool_call_id对不上会怎样?模型连续调用时上下文怎么维护?如果只把Function Calling当作“一个能让模型输出JSON的API”来用,遇到这些问题一定会手足无措。2.2 一个最小Harness的代码骨架我把自己项目里的核心循环简化成下面这段,可以作为起步参考。假设你用某个兼容ChatCompletion风格的SDK,并已经初始化好了客户端:import json MAX_ROUNDS = 5 TOOL_REGISTRY = {} def register_tool(name, schema): def decorator(func): TOOL_REGISTRY[name] = {"func": func, "schema": schema} return func return decorator @register_tool("get_weather", { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气信息", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如:北京、上海"} }, "required": ["city"] } } }) def get_weather(city: str): # 这里替换成真实天气API调用 return {"city": city, "temp": 26, "condition": "多云"} def run_agent(user_message, messages=None): messages = messages or [] messages.append({"role": "user", "content": user_message}) for _ in range(MAX_ROUNDS): resp = client.chat.completions.create( model="your-model-name", messages=messages, tools=[entry["schema"] for entry in TOOL_REGISTRY.values()], tool_choice="auto" ) msg = resp.choices[0].message if not getattr(msg, "tool_calls", None): return msg.content messages.append(msg) # 把模型这条带着tool_calls的消息加入对话 for call in msg.tool_calls: fn_name = call.function.name if fn_name not in TOOL_REGISTRY: # 模型可能生成不存在的工具名,必须兜底 messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps({"error": f"unknown tool: {fn_name}"}) }) continue args = json.loads(call.function.arguments) result = TOOL_REGISTRY[fn_name]["func"](**args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False) }) raise RuntimeError("agent loop exceeded max rounds")有几个细节值得强调。第一,messages.append(msg)这一步不能省,否则模型丢失了它自己刚才提出的调用请求,后续推理会“断片”。第二,工具执行结果必须以role="tool"回传,而且要带上对应的tool_call_id,接口层靠这个字段把结果和请求配对;tool_call_id对不上的话,平台会直接报错,这个错误在五花八门的报错里算是比较好排查的。第三,MAX_ROUNDS必须设置,否则遇到极端情况,模型可能会无限循环调用工具。3. 工具Schema的设计细节:模型“听话”与否,七成由你写的描述决定3.1 description不是写给人看的,是写给模型看的很多人写工具定义时,description就一句话:“查询天气”“获取股票信息”“创建日程”。这太吝啬了。模型没有机会“试用”你的工具,它对工具的全部理解都来自这段描述。描述写得太短,模型就会在拿不准时乱用一通。我印象很深的一个反例,是项目里有个同事写的一个“query_stock”工具,description只有四个字:“查询股票”。结果用户问“最近黄金价格怎么样”,模型也去调了这个工具,返回一堆毫无意义的数据。后续排查时我们才意识到,模型根本不知道这个工具只能查股票、不能查黄金。我自己现在写description有一条原则:写清楚“这个工具是干什么的”“在什么情况下使用”“在什么情况下绝对不能使用”。比如改成下面这样:{ "name": "query_stock", "description": "查询A股或港股的实时股价、涨跌幅和成交量。当用户询问股票行情、股价走势时使用此工具。当用户询问基金、黄金、期货等其他资产时,请勿调用此工具,直接告知用户暂不支持。", "parameters": { "type": "object", "properties": { "code": { "type": "string", "description": "股票代码:A股为6位数字(如600519),港股为5位数字(如00700)。" }, "market": { "type": "string", "enum": ["A", "HK"], "description": "市场类型:A股填A,港股填HK。可根据代码位数判断,不确定时以用户指令为准。" } }, "required": ["code"] } }多写两句话,模型的行为就会完全不同。给模型的“说明书”写得越清楚,它在真实场景里的选择就越稳健。3.2 参数Schema:陷阱重灾区参数定义是另一个容易翻车的地方。我把踩过的坑整理成几条经验:第一,required字段能少则少。模型面对缺失参数时,不会像人一样说“信息不足,请补充”,它倾向于自己“脑补”一个值。比如工具需要city和date,date不是必填时,用户只说了城市,模型很可能自己编一个“今天”塞进去。这看起来没问题,但如果你的业务对时间敏感,就会引入严重错误。所以,只有绝对必要的字段才放进required,可选字段在description里明确标注“缺省时使用XXX”。第二,能用enum就用enum。如果某个字段只有几种合法取值,一定要写枚举。比如状态字段,合法值是created和cancelled,你不写enum,模型就敢给你生成一个completed;写了enum,虽然不能100%杜绝,但至少模型在受限的选项里选择,出错的概率会低很多。第三,参数description要写单位、边界和默认值。我遇到过模型把“温度单位”默认理解成华氏度的场景,因为description里就写了“温度”两个字。后来改成“温度数值,单位为摄氏度,例如25”,问题就消失了。参数的单位、格式、取值范围、默认值,这些都是描述里必须交代的信息。第四,尽量用扁平参数,少用深层嵌套。模型对深层嵌套结构里的字段生成能力明显弱于扁平结构。有时候你会倾向把工具的入参设计成一个复杂对象,比如:{ "user": {"name": "", "age": 0}, "preferences": {"notify": true} }但实测下来,嵌套层级越多,模型漏字段、填错类型的概率就越高。我的建议是:把嵌套结构打平,用user_name、user_age、notify_user这样的扁平字段替代。看起来参数数量变多了,但模型生成的准确性会明显提升。工具Schema设计这块,本质上是在给模型“布置任务”。你布置得越清晰,模型的完成度就越高;你指望模型自己悟,它就用自己的想象力去填坑。4. 多轮调用、并行调用与Agent Loop的实操拆解4.1 模型自己会“打组合拳”:连续多轮调用当工具数量达到三四个以后,模型往往会主动发起多轮工具调用来完成一个复合任务。举个我在项目中测试过的例子:用户说“帮我约明天下午3点和合作方的视频会议,顺便订一间能坐八个人的会议室”。这个任务其实是两步:先查日程看时间是否空闲,再创建会议并预定会议室。模型在Function Calling机制下会自动这样处理:第一轮调用check_schedule,传入明天15:00,拿到空闲状态第二轮调用create_event,传入会议主题、时间、参与人第三轮调用book_room,传入人数和时段每一轮调用结束后,模型都会看到工具返回的结果,然后基于最新上下文继续决策。这意味着你的Agent Loop必须正确维护对话历史:模型的tool_calls消息、工具的结果消息、模型后续的推理消息,全部按顺序追加进messages。任何一个环节缺失,循环就会断。实际体验中,模型是否选择“打组合拳”,和工具描述、任务复杂度都有关系。任务拆解成多个步骤时,模型更倾向逐步调用;如果工具描述写得太抽象,模型也可能一步到位强行塞进单次调用里,导致参数缺失。4.2 并行调用:能省时间,但要留意隐藏依赖部分平台的接口支持一次返回多个tool_calls。比如同时查天气和查日历这两件事没有依赖关系,模型就可以把两个调用请求放在同一个响应里,你的代码并行执行两个工具函数,再把结果一次性回传。但这里有个隐蔽的坑:模型并不知道你的工具之间存在数据依赖。比如订单系统里,“创建订单”和“支付订单”在业务上必须顺序执行,先有订单号才能发起支付。但模型可能把这两个工具误认为可以并行,同时发出两个调用请求,然后你的代码在支付时发现订单号还没生成,直接抛异常。我的经验是:凡是存在明确先后依赖关系的一组操作,不要拆成多个工具让模型自己编排,而是设计成一个状态机工具。比如,把create_order和pay_order合并成一个create_and_pay_order工具,把依赖关系封装在工具函数内部。模型只需要做一次调用,返回结果天然是正确顺序。这相当于把不该模型操心的编排逻辑移到了你的确定性代码里,可靠性会大幅提升。另外,并行调用时还要注意工具函数的线程安全性。如果你的工具函数里有共享状态、锁或计数器,并发执行时可能出现数据竞争。我自己就遇到过两个工具同时写一个全局缓存,把缓存内容互相覆盖的情况。给工具函数加个互斥锁,或者干脆把工具函数设计成无状态的,能省掉很多麻烦。5. 模型会撒谎、会绕圈:工具调用中最常见的四个失控场景5.1 参数幻觉:模型会编造不存在的取值问题场景:工具定义里明确写了一个字段的enum只有low和high两个取值,模型生成arguments时,直接给了一个urgent。这种参数值幻觉,在字段定义越宽松时越容易出现。应对方式是在执行工具前加一层校验。我会用JSON Schema的校验库对模型的arguments做一次完整检验,校验不通过就返回错误信息给模型,让它重新生成。这层校验不是可选项,而是Agent系统的安全底线。模型输出的内容本质上是一个“预测结果”,不是真实数据,永远不要直接信任。5.2 工具结果太长,上下文Token爆炸场景:你的工具函数返回了一大段数据库记录,或者一份几千字的报告文本。每调用一次,这段结果都会作为消息塞进对话上下文。用户提问一句,Agent内部调用3个工具,每个工具返回2000字,上下文瞬间多出6000字。连续多轮对话后,Token消耗指数级上升,费用和响应延迟都跟着涨。我现在的做法是在工具函数内部做“结果瘦身”。比如,天气接口只返回核心字段,不要全量透传;搜索接口只返回标题和摘要,而不是完整正文。还有一个技巧:如果结果确实很长,可以在工具函数里把内容切成两段——发给模型的是一段摘要,完整数据另外存起来,等模型需要细节时再通过另一个查询工具获取。5.3 死循环:模型反复调用同一个工具这是最让人血压升高的一种失控。模型为了获取一个信息,连续调用同一个工具三四次,每次返回的内容都差不多,它却始终不把结果整合成最终答案。我遇到过最离谱的一次,是模型连续调用了五次同一个查询工具,每次的入参都一样,循环就是结束不了。出现这种情况,通常是工具返回的信息没有触及模型关心的核心点,或者模型压根已经忘了前面调过这个工具。我的解法有三个,叠加使用效果很好:设置最大迭代轮数,超时就强制终止循环,返回最后一条可用回复记录同一个连续工具调用的次数,超过两次就打断,在下一轮提示中提醒模型“该工具最近已被调用过,请直接基于返回结果作答”在系统提示词里明确写:“如果需要的信息已经出现在之前的工具结果中,不要重复调用,直接回答用户”5.4 工具选择失误:该调的不调,不该调的乱调模型工具选择失误有两个方向。一种是该调工具时不调,比如用户问“我的账户余额是多少”,模型直接就着历史记忆编了一个数字;另一种是不该调时偏要调,比如用户只是闲聊问“今天天气如何是怎么测出来的”,模型也跑去调天气API。这两个问题都指向同一件事:模型对“什么情况该用工具”缺乏准确理解。除了在工具description里写清楚边界,我还会在System Prompt里加一段总体的工具使用原则,例如:“当用户的信息需求可能随时间或环境变化时,应使用工具获取实时数据;当问题涉及事实性知识且与实时状态无关时,直接回答。”6. 日志留痕与评测集搭建:把Agent从“能跑”推向“可信”6.1 日志是你唯一的线索Agent系统调试起来比普通接口痛苦得多。普通接口出错,报错信息直接告诉你哪里坏了;Agent出错,往往要回溯好几轮对话,才能看清模型是在哪一步做了错误决策。没有日志,排查这类问题就像在黑暗里找钥匙。我现在给工具调用流程加的日志,至少要记录这些字段:记录项说明request_id一次会话的追踪ID,方便把多轮调用串起来round_index当前是第几轮工具循环model_output_tool_calls模型发起的调用请求原文tool_name / args实际执行了哪个工具、用了什么参数execution_result_summary工具执行结果摘要,不用记全文,太长duration_ms每轮调用的耗时final_answer模型的最终回复原文这些日志的价值在模型升级时最能体现。有一次我迁移到新版本模型,用户反馈“动不动就乱调工具”,我翻了日志,发现新模型在工具选择上确实比旧版激进得多,几乎每个问题都会强行调一次工具。没有日志的话,我可能还在怀疑是自己的代码出了问题。6.2 给Agent建一套长期评测集很多人做Agent时只关注“跑通Demo”,忽略了“长期可维护”。模型版本会迭代,工具定义会修改,提示词会调整,你怎么知道每次改动是变好了还是变差了?这就需要一套固定评测集。我的做法是准备30个典型任务,覆盖单工具查询、多工具顺序调用、参数格式转换、边界模糊问题这几类场景。每次改动之后跑一遍,统计这几个指标:工具选择正确率:模型选对工具的比例参数正确率:每次调用生成参数合法且有意义的比例任务完成率:评测集里有多少任务被完整解决平均工具调用轮数:衡量效率,轮数过高说明模型在“绕路”失败原因分布:是参数幻觉、工具选择失误,还是循环没走出来这套评测集的价值会随着时间慢慢显现。它不只是验收标准,更是你在做优化时的“尺子”。没有尺子,你所有的调优都只能凭感觉,改完也不知道是真变好了还是心理作用。最后聊点实在的做完整个项目,我最深的感触是:Function Calling本身并不难,难的是围绕它搭起的整套Harness。同样的模型,有人接出来的Agent稳如老狗,有人接出来的Agent疯如脱兔,差别往往不在模型,而在工具定义的质量和循环处理的健壮性。还有一个小技巧,是我后来才加上的,但效果出奇地好:给工具函数设计成幂等的。同一组参数执行一次和执行两次,结果一样,不会重复扣费、重复下单、重复创建会议室。这样就算模型偶尔重复调用某个工具,业务侧也不会出问题。这个经验也是我在踩了几次重复下单的坑之后才总结出来的。希望你不用踩同样的坑。
上个月我在做一款带工具的对话助手,核心需求是让模型在需要时能调用天气查询、日程管理这类外部能力。最开始建模的时候,我用了最省事的方案:在系统提示词里把JSON格式写得明明白白,让模型自己输出动作和参数,然后在代码里解析。结果整个联调阶段都在填自然语言解析的坑——模型有时把JSON包在Markdown代码块里,有时在JSON前面多加一句"好的,我来为您查询",有时把参数名从双引号改成单引号,最离谱的一次它把整个JSON对象的键值对顺序全打乱了,我的正则匹配直接瘫痪。后来换成模型接口原生支持的Function Calling机制,才真正体会到"专业的事要交给专业的机制去做"这句话的分量。这篇文章想以一个踩坑者而不是理论家的身份,把Function Calling从表层API调用到系统集成再到工程化落地完整拆一遍,顺带聊聊标题里那个听起来有点唬人的概念:AI Agent Harness Engineering——按我的理解,它就是那个把模型大脑和外部工具手脚衔接起来的“传动系统”。1. 从一次翻车的JSON手写解析,透视Function Calling的本质1.1 没有Function Calling的时代,Prompt输出JSON有多不靠谱先还原一下我最初的做法。面对“北京今天需要带伞吗”这种问题,我会在系统提示词里写:如果需要查询天气,请输出以下JSON格式: {"tool": "get_weather", "params": {"city": "北京"}} 不要输出其他内容。这段提示词在Demo场景下能跑通,但真实请求一多就原形毕露。模型毕竟是语言模型,它终归倾向于“说人话”,而不是做严格的格式输出。我实际遇到的典型情况有这么几类:在JSON外层包Markdown代码块,需要先用正则剥离输出里混入解释性文字,例如“根据您的需求,我查询了天气信息:”键名被改写,比如把params变成了parameters单引号替换双引号,null被写成None,布尔值写成字符串每一类我都得写对应的解析逻辑和重试策略。最让人崩溃的是,模型升级之后行为还会变——以前不包代码块的,升级后突然开始包了。你的解析代码永远在追赶模型的行为变化。1.2 Function Calling接口的真实工作方式功能调用机制和上述做法有本质区别:它不是要求模型在自然语言文本里“附带”一段JSON,而是让模型在解码阶段就被引导去生成一个结构化的工具调用请求。这个请求会作为一个独立的、带有固定字段的消息返回,不掺杂任何自然语言修饰。以常见的接口格式为例,模型返回的消息里会多出一个tool_calls数组,每个元素大概长这样:{ "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } }注意arguments依旧是一个JSON字符串,需要你自己解析。但它和Prompt手写JSON有本质区别:模型在arguments里生成的内容更加规范,不会出现“好的,我来查询”这种废话,也不会把双引号换成单引号。原因在于接口层在训练和推理时都针对结构化输出做了对齐,模型已经形成了“这个位置就应该输出合法JSON”的条件反射。如果你以为这个机制是万无一失的,那又掉进另一个坑了。实际体验下来,它只是把“从自然语言里猜JSON”的问题率降到了可接受范围,而不是降到了零。参数值幻觉、调用不存在的工具名、嵌套对象残缺,这些问题依然存在,我在第5章会专门展开。可以把三种方案做个直观对比:方案输出稳定性可控性解析成本适用场景Prompt手写JSON极差,随模型版本波动低高,需要大量正则和重试轻量体验、内部原型JSON Mode / Output Schema较好,但只保证格式中中,仍需二次校验需要纯结构化输出、不涉及工具选择Function Calling好,工具调用是原生能力高,可选指定工具低,字段固定Agent系统、多工具场景、复杂任务2. Harness Engineering:模型、工具与执行循环如何拧成一股绳2.1 “手脚”之外,更重要的是传动系统Function Calling本身只是给模型开了一扇“可以调用工具”的门。但要让Agent真正稳定地完成一连串任务,你需要围绕这个功能搭建一整套工程机制,这套机制就是标题里说的Harness Engineering。打个比方:LLM是“大脑”,负责理解意图和做决策;工具是“手和脚”,负责触碰外部世界;而Harness Engineering是“神经和骨骼系统”,负责把大脑的命令准确无误地传导到手脚,再把手脚感知到的信息传回大脑。没有这套传动系统,大脑再聪明,手脚也是瘫着的。一个最简Agent系统的循环长这样:用户输入 → 系统提示词 + 工具定义列表 → 模型模型推理,产出结论或调用请求如果有调用请求 → 代码解析请求 → 执行对应工具函数 → 拿到结果把工具结果作为新消息回填给模型模型收到结果后继续推理,可能再发起新一轮调用,直到给出最终答复这个看似简单的循环里,每一步都有陷阱:工具结果应该以什么角色消息回传?tool_call_id对不上会怎样?模型连续调用时上下文怎么维护?如果只把Function Calling当作“一个能让模型输出JSON的API”来用,遇到这些问题一定会手足无措。2.2 一个最小Harness的代码骨架我把自己项目里的核心循环简化成下面这段,可以作为起步参考。假设你用某个兼容ChatCompletion风格的SDK,并已经初始化好了客户端:import json MAX_ROUNDS = 5 TOOL_REGISTRY = {} def register_tool(name, schema): def decorator(func): TOOL_REGISTRY[name] = {"func": func, "schema": schema} return func return decorator @register_tool("get_weather", { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气信息", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如:北京、上海"} }, "required": ["city"] } } }) def get_weather(city: str): # 这里替换成真实天气API调用 return {"city": city, "temp": 26, "condition": "多云"} def run_agent(user_message, messages=None): messages = messages or [] messages.append({"role": "user", "content": user_message}) for _ in range(MAX_ROUNDS): resp = client.chat.completions.create( model="your-model-name", messages=messages, tools=[entry["schema"] for entry in TOOL_REGISTRY.values()], tool_choice="auto" ) msg = resp.choices[0].message if not getattr(msg, "tool_calls", None): return msg.content messages.append(msg) # 把模型这条带着tool_calls的消息加入对话 for call in msg.tool_calls: fn_name = call.function.name if fn_name not in TOOL_REGISTRY: # 模型可能生成不存在的工具名,必须兜底 messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps({"error": f"unknown tool: {fn_name}"}) }) continue args = json.loads(call.function.arguments) result = TOOL_REGISTRY[fn_name]["func"](**args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False) }) raise RuntimeError("agent loop exceeded max rounds")有几个细节值得强调。第一,messages.append(msg)这一步不能省,否则模型丢失了它自己刚才提出的调用请求,后续推理会“断片”。第二,工具执行结果必须以role="tool"回传,而且要带上对应的tool_call_id,接口层靠这个字段把结果和请求配对;tool_call_id对不上的话,平台会直接报错,这个错误在五花八门的报错里算是比较好排查的。第三,MAX_ROUNDS必须设置,否则遇到极端情况,模型可能会无限循环调用工具。3. 工具Schema的设计细节:模型“听话”与否,七成由你写的描述决定3.1 description不是写给人看的,是写给模型看的很多人写工具定义时,description就一句话:“查询天气”“获取股票信息”“创建日程”。这太吝啬了。模型没有机会“试用”你的工具,它对工具的全部理解都来自这段描述。描述写得太短,模型就会在拿不准时乱用一通。我印象很深的一个反例,是项目里有个同事写的一个“query_stock”工具,description只有四个字:“查询股票”。结果用户问“最近黄金价格怎么样”,模型也去调了这个工具,返回一堆毫无意义的数据。后续排查时我们才意识到,模型根本不知道这个工具只能查股票、不能查黄金。我自己现在写description有一条原则:写清楚“这个工具是干什么的”“在什么情况下使用”“在什么情况下绝对不能使用”。比如改成下面这样:{ "name": "query_stock", "description": "查询A股或港股的实时股价、涨跌幅和成交量。当用户询问股票行情、股价走势时使用此工具。当用户询问基金、黄金、期货等其他资产时,请勿调用此工具,直接告知用户暂不支持。", "parameters": { "type": "object", "properties": { "code": { "type": "string", "description": "股票代码:A股为6位数字(如600519),港股为5位数字(如00700)。" }, "market": { "type": "string", "enum": ["A", "HK"], "description": "市场类型:A股填A,港股填HK。可根据代码位数判断,不确定时以用户指令为准。" } }, "required": ["code"] } }多写两句话,模型的行为就会完全不同。给模型的“说明书”写得越清楚,它在真实场景里的选择就越稳健。3.2 参数Schema:陷阱重灾区参数定义是另一个容易翻车的地方。我把踩过的坑整理成几条经验:第一,required字段能少则少。模型面对缺失参数时,不会像人一样说“信息不足,请补充”,它倾向于自己“脑补”一个值。比如工具需要city和date,date不是必填时,用户只说了城市,模型很可能自己编一个“今天”塞进去。这看起来没问题,但如果你的业务对时间敏感,就会引入严重错误。所以,只有绝对必要的字段才放进required,可选字段在description里明确标注“缺省时使用XXX”。第二,能用enum就用enum。如果某个字段只有几种合法取值,一定要写枚举。比如状态字段,合法值是created和cancelled,你不写enum,模型就敢给你生成一个completed;写了enum,虽然不能100%杜绝,但至少模型在受限的选项里选择,出错的概率会低很多。第三,参数description要写单位、边界和默认值。我遇到过模型把“温度单位”默认理解成华氏度的场景,因为description里就写了“温度”两个字。后来改成“温度数值,单位为摄氏度,例如25”,问题就消失了。参数的单位、格式、取值范围、默认值,这些都是描述里必须交代的信息。第四,尽量用扁平参数,少用深层嵌套。模型对深层嵌套结构里的字段生成能力明显弱于扁平结构。有时候你会倾向把工具的入参设计成一个复杂对象,比如:{ "user": {"name": "", "age": 0}, "preferences": {"notify": true} }但实测下来,嵌套层级越多,模型漏字段、填错类型的概率就越高。我的建议是:把嵌套结构打平,用user_name、user_age、notify_user这样的扁平字段替代。看起来参数数量变多了,但模型生成的准确性会明显提升。工具Schema设计这块,本质上是在给模型“布置任务”。你布置得越清晰,模型的完成度就越高;你指望模型自己悟,它就用自己的想象力去填坑。4. 多轮调用、并行调用与Agent Loop的实操拆解4.1 模型自己会“打组合拳”:连续多轮调用当工具数量达到三四个以后,模型往往会主动发起多轮工具调用来完成一个复合任务。举个我在项目中测试过的例子:用户说“帮我约明天下午3点和合作方的视频会议,顺便订一间能坐八个人的会议室”。这个任务其实是两步:先查日程看时间是否空闲,再创建会议并预定会议室。模型在Function Calling机制下会自动这样处理:第一轮调用check_schedule,传入明天15:00,拿到空闲状态第二轮调用create_event,传入会议主题、时间、参与人第三轮调用book_room,传入人数和时段每一轮调用结束后,模型都会看到工具返回的结果,然后基于最新上下文继续决策。这意味着你的Agent Loop必须正确维护对话历史:模型的tool_calls消息、工具的结果消息、模型后续的推理消息,全部按顺序追加进messages。任何一个环节缺失,循环就会断。实际体验中,模型是否选择“打组合拳”,和工具描述、任务复杂度都有关系。任务拆解成多个步骤时,模型更倾向逐步调用;如果工具描述写得太抽象,模型也可能一步到位强行塞进单次调用里,导致参数缺失。4.2 并行调用:能省时间,但要留意隐藏依赖部分平台的接口支持一次返回多个tool_calls。比如同时查天气和查日历这两件事没有依赖关系,模型就可以把两个调用请求放在同一个响应里,你的代码并行执行两个工具函数,再把结果一次性回传。但这里有个隐蔽的坑:模型并不知道你的工具之间存在数据依赖。比如订单系统里,“创建订单”和“支付订单”在业务上必须顺序执行,先有订单号才能发起支付。但模型可能把这两个工具误认为可以并行,同时发出两个调用请求,然后你的代码在支付时发现订单号还没生成,直接抛异常。我的经验是:凡是存在明确先后依赖关系的一组操作,不要拆成多个工具让模型自己编排,而是设计成一个状态机工具。比如,把create_order和pay_order合并成一个create_and_pay_order工具,把依赖关系封装在工具函数内部。模型只需要做一次调用,返回结果天然是正确顺序。这相当于把不该模型操心的编排逻辑移到了你的确定性代码里,可靠性会大幅提升。另外,并行调用时还要注意工具函数的线程安全性。如果你的工具函数里有共享状态、锁或计数器,并发执行时可能出现数据竞争。我自己就遇到过两个工具同时写一个全局缓存,把缓存内容互相覆盖的情况。给工具函数加个互斥锁,或者干脆把工具函数设计成无状态的,能省掉很多麻烦。5. 模型会撒谎、会绕圈:工具调用中最常见的四个失控场景5.1 参数幻觉:模型会编造不存在的取值问题场景:工具定义里明确写了一个字段的enum只有low和high两个取值,模型生成arguments时,直接给了一个urgent。这种参数值幻觉,在字段定义越宽松时越容易出现。应对方式是在执行工具前加一层校验。我会用JSON Schema的校验库对模型的arguments做一次完整检验,校验不通过就返回错误信息给模型,让它重新生成。这层校验不是可选项,而是Agent系统的安全底线。模型输出的内容本质上是一个“预测结果”,不是真实数据,永远不要直接信任。5.2 工具结果太长,上下文Token爆炸场景:你的工具函数返回了一大段数据库记录,或者一份几千字的报告文本。每调用一次,这段结果都会作为消息塞进对话上下文。用户提问一句,Agent内部调用3个工具,每个工具返回2000字,上下文瞬间多出6000字。连续多轮对话后,Token消耗指数级上升,费用和响应延迟都跟着涨。我现在的做法是在工具函数内部做“结果瘦身”。比如,天气接口只返回核心字段,不要全量透传;搜索接口只返回标题和摘要,而不是完整正文。还有一个技巧:如果结果确实很长,可以在工具函数里把内容切成两段——发给模型的是一段摘要,完整数据另外存起来,等模型需要细节时再通过另一个查询工具获取。5.3 死循环:模型反复调用同一个工具这是最让人血压升高的一种失控。模型为了获取一个信息,连续调用同一个工具三四次,每次返回的内容都差不多,它却始终不把结果整合成最终答案。我遇到过最离谱的一次,是模型连续调用了五次同一个查询工具,每次的入参都一样,循环就是结束不了。出现这种情况,通常是工具返回的信息没有触及模型关心的核心点,或者模型压根已经忘了前面调过这个工具。我的解法有三个,叠加使用效果很好:设置最大迭代轮数,超时就强制终止循环,返回最后一条可用回复记录同一个连续工具调用的次数,超过两次就打断,在下一轮提示中提醒模型“该工具最近已被调用过,请直接基于返回结果作答”在系统提示词里明确写:“如果需要的信息已经出现在之前的工具结果中,不要重复调用,直接回答用户”5.4 工具选择失误:该调的不调,不该调的乱调模型工具选择失误有两个方向。一种是该调工具时不调,比如用户问“我的账户余额是多少”,模型直接就着历史记忆编了一个数字;另一种是不该调时偏要调,比如用户只是闲聊问“今天天气如何是怎么测出来的”,模型也跑去调天气API。这两个问题都指向同一件事:模型对“什么情况该用工具”缺乏准确理解。除了在工具description里写清楚边界,我还会在System Prompt里加一段总体的工具使用原则,例如:“当用户的信息需求可能随时间或环境变化时,应使用工具获取实时数据;当问题涉及事实性知识且与实时状态无关时,直接回答。”6. 日志留痕与评测集搭建:把Agent从“能跑”推向“可信”6.1 日志是你唯一的线索Agent系统调试起来比普通接口痛苦得多。普通接口出错,报错信息直接告诉你哪里坏了;Agent出错,往往要回溯好几轮对话,才能看清模型是在哪一步做了错误决策。没有日志,排查这类问题就像在黑暗里找钥匙。我现在给工具调用流程加的日志,至少要记录这些字段:记录项说明request_id一次会话的追踪ID,方便把多轮调用串起来round_index当前是第几轮工具循环model_output_tool_calls模型发起的调用请求原文tool_name / args实际执行了哪个工具、用了什么参数execution_result_summary工具执行结果摘要,不用记全文,太长duration_ms每轮调用的耗时final_answer模型的最终回复原文这些日志的价值在模型升级时最能体现。有一次我迁移到新版本模型,用户反馈“动不动就乱调工具”,我翻了日志,发现新模型在工具选择上确实比旧版激进得多,几乎每个问题都会强行调一次工具。没有日志的话,我可能还在怀疑是自己的代码出了问题。6.2 给Agent建一套长期评测集很多人做Agent时只关注“跑通Demo”,忽略了“长期可维护”。模型版本会迭代,工具定义会修改,提示词会调整,你怎么知道每次改动是变好了还是变差了?这就需要一套固定评测集。我的做法是准备30个典型任务,覆盖单工具查询、多工具顺序调用、参数格式转换、边界模糊问题这几类场景。每次改动之后跑一遍,统计这几个指标:工具选择正确率:模型选对工具的比例参数正确率:每次调用生成参数合法且有意义的比例任务完成率:评测集里有多少任务被完整解决平均工具调用轮数:衡量效率,轮数过高说明模型在“绕路”失败原因分布:是参数幻觉、工具选择失误,还是循环没走出来这套评测集的价值会随着时间慢慢显现。它不只是验收标准,更是你在做优化时的“尺子”。没有尺子,你所有的调优都只能凭感觉,改完也不知道是真变好了还是心理作用。最后聊点实在的做完整个项目,我最深的感触是:Function Calling本身并不难,难的是围绕它搭起的整套Harness。同样的模型,有人接出来的Agent稳如老狗,有人接出来的Agent疯如脱兔,差别往往不在模型,而在工具定义的质量和循环处理的健壮性。还有一个小技巧,是我后来才加上的,但效果出奇地好:给工具函数设计成幂等的。同一组参数执行一次和执行两次,结果一样,不会重复扣费、重复下单、重复创建会议室。这样就算模型偶尔重复调用某个工具,业务侧也不会出问题。这个经验也是我在踩了几次重复下单的坑之后才总结出来的。希望你不用踩同样的坑。