ARTICLE DETAIL

资讯详情

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

Mistral函数调用与结构化输出:从聊天到系统集成的工程实践

Mistral函数调用与结构化输出:从聊天到系统集成的工程实践 前五篇我们聊完了 Mistral 的 API 基础调用、模型选型、采样参数调整、流式输出和简单的检索增强。能跟到第六篇的读者基本都是已经开始用 Mistral 做真实项目的人了。一旦你开始做真实项目就会立刻撞上一个很现实的问题模型确实能说会道但我要的不是一段漂亮话而是它能按我的要求去查数据库、调内部接口、把结果格式化成系统能消费的结构。这篇就专门解决这件事核心就两个主题函数调用Function Calling和结构化输出。这篇内容面向这个阶段的人已经调通 API想更进一步把模型嵌入到真实业务系统里或者你只是想知道怎么让模型稳定输出 JSON而不是心累地在提示词里写一万遍“请以 JSON 格式返回”。我把完整代码、可运行的案例和踩过的坑都放在下面你跟着操作一遍基本就能在自己项目里落地。1. 函数调用与结构化输出解决什么问题1.1 为什么说这是应用开发的转折点先讲个类比。你第一次用大模型 API相当于雇了一个只动嘴的客服。你问它什么它都能答但它没有任何实际行动能力。你说“帮我查一下订单物流”它只能回复“建议您联系客服”。你让它“把这份数据整理成表格”它可能给你一段 Markdown格式还随心所欲。函数调用就是给这个客服装上手脚。模型负责理解意图、提取参数、决定该调用哪个动作真正执行动作的是你自己的代码。模型不直接碰你的数据库它只是生成一个结构化的调用指令比如“去执行 query_order_status 这个函数参数是 order_idSD20240610001”然后你的程序拿到这个指令去查库再把结果回传给模型模型基于真实结果组织最终回答。这一下就把大模型从“聊天玩具”变成了“业务系统的一个调度层”。我在实际项目里最明显的感受是有了函数调用之后模型的角色变了它不再是最终答案的产出者而是一个意图识别器和对话管理器。你不需要再担心模型胡编乱造一个订单状态因为它压根不负责编数据它只负责决定调用哪个工具数据从你的系统里来再经由它润色成用户能看懂的话。结构化输出解决的是另一面。很多时候你并不需要模型生成自然语言你需要的是一份能直接json.loads的机器可读数据。比如从文本里抽一条订单信息字段是{order_id, amount, status}你希望它每一次都严格按这个 schema 返回。提示词里写再多的“必须”都不如用一个参数来约束官方提供的response_format{type: json_object}就是干这个的。1.2 与单纯提示词约束的差别肯定有人问我不就是想在提示词里让它输出 JSON 吗这两者区别在哪我用一个表格说清楚对比项只在提示词里要求使用结构化输出参数格式稳定性看模型心情偶尔夹带解释文字强制按 JSON 返回不夹带其他内容解析成本你还在写正则清洗模型回答直接json.loads干净利落报错反馈模型根本不知道自己在犯错API 层或运行时能稳定进入可重试流程适合场景Demo、可读性优先的对话对接业务系统、数据管道、自动化流程函数调用和结构化输出的组合逻辑是这样的函数调用解决“该做什么”结构化输出解决“做完之后如何把结果交付给系统”。一个偏动作一个偏数据两者合起来才是完整的工程化能力。2. 动手前的准备工作2.1 环境与模型选择建议我默认你已经配好 Python 环境也拿到了 Mistral API key。没有的先去平台申请然后把 key 设置到环境变量里。这次我们用官方 SDK安装就一条命令pip install -U mistralai模型选择上我的建议是函数调用场景优先用mistral-large-latest它在工具选择、参数抽取、多轮 tool 调用上的稳定性明显好于小模型。mistral-small-latest便宜但我在复杂场景里遇到过它把参数名写错、或者该调用工具的时候选择直接编答案的情况。如果是内部工具少、场景简单的 demosmall 能用如果要上生产别省这个钱。有一点值得专门提醒Mistral 还提供了 OpenAI 兼容的 API 端点。如果你以前用惯了 openai 库可以不换 SDK只需要改base_url和api_key。但我在实际项目里踩过兼容层的坑某些版本的工具调用返回格式存在细微差异排查起来很浪费精力。能用官方 SDK 就用官方 SDK少一层兼容转换就少一类神秘报错。2.2 参数配合的基本功很多考虑过函数调用的朋友都忽略了一个细节temperature 没调。模型生成 function call 的 JSON 参数时同样是采样行为temperature 高就更容易在参数值里引入不存在的字段或者错误拼写。我现在的经验是函数调用场景直接拉到 0.1甚至 0让模型尽可能保守按字面意思抽取用户输入里的参数。自然语言回复那边你要是嫌太机械再单独把最终回答那一次请求的 temperature 调回 0.7 左右即可。还有一个容易被截断影响的参数max_tokens。模型输出 tool call 的结构本身要占不少 token如果上限设得太小有的实现会直接给你截断出一个残缺的 JSON后面解析必炸。我一般函数调用场景起步给 1024如果是复杂工具定义加多轮调用2048 更稳妥。具体的比例在第六节排查部分再细算。3. 函数调用完整实操3.1 写一个合法的工具定义先说结论工具定义的语法是嵌套 JSON核心点很容易记错。最外层是一个数组每一项包含type和function两个字段真正写参数 schema 的地方在function.parameters里面。我拿一个实际例子演示定义一个“查询订单状态”工具tools [ { type: function, function: { name: query_order_status, description: 查询用户在电商平台的订单物流状态。当用户询问订单到哪里、订单状态、订单什么时候送到时使用。, parameters: { type: object, properties: { order_id: { type: string, description: 订单编号例如 SD20240610001 } }, required: [order_id] } } } ]这个结构有几个特别容易被坑到的地方第一parameters不能放到function外面也不能直接用 JSON Schema 顶层格式必须套在properties里面。第二每个参数的description写得越具体模型提取参数的准确率越高。比如上面我写了“当用户询问订单到哪里、订单状态时”模型就能把“送到哪了”这种口语表达和这个工具关联起来。第三required一定要认真写。如果你的工具没有这个字段模型可能调用时不传参数你的代码就会因为缺 key 直接抛异常。3.2 两段式调用核心逻辑流程函数调用和普通对话最大的区别就是调用流程变长了。第一次请求不是用来拿最终答案的而是让模型决定“要不要调工具、调哪个工具、参数是什么”。拿到这个决策之后你的程序去执行真实函数再把执行结果塞回去发起第二次请求模型才基于真实数据给你最终回复。直接看代码from mistralai import Mistral import json import os client Mistral(api_keyos.environ[MISTRAL_API_KEY]) messages [ {role: user, content: 帮我查一下订单 SD20240610001 送到哪了} ] resp client.chat.complete( modelmistral-large-latest, messagesmessages, toolstools, temperature0.1, ) msg resp.choices[0].message print(finish_reason:, resp.choices[0].finish_reason) print(tool_calls:, msg.tool_calls)运行之后你会看到类似这样的输出finish_reason: tool_calls tool_calls: [ToolCall( id..., functionFunctionCall( namequery_order_status, arguments{order_id: SD20240610001} ) )]注意几个关键点finish_reason是tool_calls而不是stop看到这个值说明模型确实要走工具分支。tool_calls是一个列表说明在一次响应里可以让模型并行调用多个工具。arguments是一个 JSON 字符串不是字典需要你自己json.loads解析。这地方我记得很清楚第一次做的时候直接当成字典取属性直接 TypeError印象极深。拿到调用指令后你的程序执行真实逻辑def query_order_status(order_id): data { SD20240610001: {status: 已签收, summary: 快递于6月18日送达}, SD20240610002: {status: 运输中, summary: 当前在转运中心}, } return data.get(order_id, {status: 未查到, summary: 没有这个订单号}) tool_map { query_order_status: query_order_status, } # 解析并执行 tool_call msg.tool_calls[0] fn_name tool_call.function.name fn_args json.loads(tool_call.function.arguments) result tool_map[fn_name](fn_args[order_id])接下来把模型刚才的响应和工具执行结果一起拼进消息列表发起第二轮请求messages.append(msg) # assistant 的响应里面带着 tool_calls messages.append({ role: tool, tool_call_id: tool_call.id, # 必须对上 content: json.dumps(result, ensure_asciiFalse) }) resp2 client.chat.complete( modelmistral-large-latest, messagesmessages, toolstools, temperature0.3, ) print(resp2.choices[0].message.content)这时候模型会看到工具返回的真实数据然后组织一句自然语言回答比如“您的订单 SD20240610001 已经签收快递于6月18日送达。”我之前总是搞不明白 tool 消息长什么样后来总结成一句口诀tool 消息就是“你说调用了这个函数那我把它执行的原始结果反馈给你”。tool_call_id一定要和刚才 assistant 消息里的 id 对应上否则 API 直接报错。3.3 多工具场景下的选择策略实际项目里不会只有一个工具。当你同时传多个工具给模型模型得自己判断应该调哪个。这其实是模型推理能力的一个缩影你给的工具描述越清楚它选得越准。如果你的场景是一次调用需要多个数据源比如既要知道天气又要知道订单状态Mistral 允许模型在一个响应里返回多个tool_calls。我的策略是先告诉模型在什么场景下可以并行调用比如工具描述里写“当用户询问行程安排和时间时可同时查询天气和航班”。模型在 decision stage 直接返回两个调用你的程序循环处理每一个调用再把结果全部回传。还有一个参数tool_choice。默认是auto让模型自己决定调不调。如果你确定用户在当前场景下必须有工具介入可以传any强制模型至少要选一个工具。我测试下来any在手头这个场景里非常好用比如对话类应用已经跑到一个专门的工具状态机里不希望模型自作聪明用常识回答。反过来如果你不想让它调用任何工具传none等效于一次普通对话。这里面没有银弹不同值需要配合不同业务阶段。4. 结构化输出的正确姿势4.1 用 response_format 强制 JSON前面说过函数调用的参数本身就是 JSON但很多场景你其实不需要“调用工具”你只是想让模型的最终输出是可控的 JSON。Mistral 提供了一个比提示词硬得多的手段resp client.chat.complete( modelmistral-large-latest, messages[ {role: user, content: 从这段文字中抽取订单信息字段包括 order_id 和 amount。文字订单编号 SD20240610001实付金额 99.9 元。请以 json 输出。} ], response_format{type: json_object}, temperature0, ) content resp.choices[0].message.content print(content) # 输出{order_id: SD20240610001, amount: 99.9}这里有一个官方文档没写透、但我不希望你踩坑的点当你启用json_object模式时提示词里必须出现“json”这个词否则 API 会直接报错。我第一次用的时候提示词里只写了“按如下格式输出order_id...”结果报了一个很诡异的格式错误排查半天才发现是缺了关键字。现在我已经习惯在提示词末尾固定补一句“结果以 json 格式返回”。输出稳定之后你可以在前端直接把这个 JSON 传给表单、图表组件或者存入数据库省去所有清洗步骤。我做过一个内部流程把客服对话记录批量喂给模型让模型按统一 schema 输出客户意图和情绪标签跑了一整夜没出过一次解析错误。这种批量场景json_object模式几乎是必须的。4.2 与函数调用如何分工很多人搞混这两个能力。我提供一个非常简单的判断标准如果这个动作需要你的系统做点事情比如发邮件、查数据库、改配置应该用函数调用如果动作只是“把数据整理成某种结构返回”比如信息抽取、格式化、打标用json_object模式就够了。函数调用涉及你的业务代码介入有副作用需要慎重。JSON 模式是纯文本处理不产生实际动作。我的建议是业务工具优先用函数调用承载而不要通过让模型输出 JSON、再让代码解析 JSON 里的指令去执行。为什么因为函数调用由模型直接产出结构化的函数签名有 API 层面的校验反过来你在 JSON 里自定一个 action 字段模型生成错一个字段名你的执行层就可能跳过或误执行极危险。4.3 确保 JSON 解析稳定的细节启用json_object模式不代表百分之百没问题。输出很长的时候max_tokens如果不够返回的 JSON 会被截断在中间后半段是半截字符串json.loads直接抛异常。这种问题定位很容易看 content 尾部是不是戛然而止再看 token 使用量是不是刚好顶满上限。这时候要嘛调大max_tokens要嘛把输出拆短。另一个细节是我希望你能尽早养成的习惯把模型返回的 JSON 先放进 pydantic 或者 dataclass 校验而不是直接json.loads完就当生产资料。模型生成的键名可能在一次输出里合法下一次同一个语义下就变成orderId和order_id混着来。你写个模型类强校验一下字段不对就触发重试整体稳定性会高一个数量级。我会在后一节综合案例里演示这个思路。5. 一个可运行的完整案例多工具电商小助手5.1 场景与工具定义理论讲再多不如一跑。我设计一个贴近真实业务的小场景用户进来问订单相关的问题也可能会问天气比如问“下雨了快递会不会延迟”这个小助手需要同时接入“订单查询”和“天气查询”两个工具并根据用户意图自动选择调用。除了直接回答问题这个案例我还想让你看到循环处理多个工具调用的完整代码结构。这里是完整定义tools [ { type: function, function: { name: query_order_status, description: 查询订单物流状态。当用户提供订单号或询问订单是否送达、在哪、何时到达时使用。, parameters: { type: object, properties: { order_id: { type: string, description: 订单编号例如 SD20240610001 } }, required: [order_id] } } }, { type: function, function: { name: query_weather, description: 查询城市实时天气。当用户询问某个地方是否下雨、气温、天气情况时使用。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如 上海 } }, required: [city] } } } ]两个工具的描述写得很刻意把用户可能说的口语都揉进去了。这是我在实际项目中总结的经验不要只写“查询天气”要多写“当用户询问是否下雨、气温、出行是否受影响时使用”。模型是通过描述来选择工具的描述是它的第一线索。教师曾经在一句话里同时问“我的订单和杭州天气”模型把两个工具都选了出来这就是描述到位的结果。5.2 完整的执行循环代码下面是整个循环从用户输入到最终自然语言回复一步到位from mistralai import Mistral import json import os client Mistral(api_keyos.environ[MISTRAL_API_KEY]) def query_order_status(order_id): db { SD20240610001: {status: 已签收, detail: 6月18日已由前台代收}, SD20240610002: {status: 运输中, detail: 正在发往本地分拣中心}, } info db.get(order_id, {status: 未查到, detail: 请确认订单号}) return {order_id: order_id, **info} def query_weather(city): db { 北京: {weather: 晴, temp: 24}, 上海: {weather: 小雨, temp: 26}, 杭州: {weather: 多云, temp: 23}, } info db.get(city, {weather: 未知, temp: 未知}) return {city: city, **info} tool_map { query_order_status: query_order_status, query_weather: query_weather, } messages [ {role: user, content: 帮我看下 SD20240610001 这个订单到哪了顺便问一下上海现在下雨没有} ] for _ in range(5): # 最多循环5次防止模型死循环 resp client.chat.complete( modelmistral-large-latest, messagesmessages, toolstools, temperature0.1, max_tokens1024, ) choice resp.choices[0] msg choice.message if choice.finish_reason ! tool_calls: print(最终回复, msg.content) break messages.append(msg) for tc in msg.tool_calls: fn_name tc.function.name fn_args json.loads(tc.function.arguments) print(f调用工具{fn_name}参数{fn_args}) result tool_map[fn_name](**fn_args) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse) })跑一下你会看到控制台输出类似这样的过程调用工具query_order_status参数{order_id: SD20240610001} 调用工具query_weather参数{city: 上海} 最终回复您的订单 SD20240610001 已在 6 月 18 日签收由前台代收。目前上海正下着小雨气温 26 度收货时如果出门留意防雨。注意我特意加了for _ in range(5)这个外层循环。工具调用有可能是多轮接力比如模型拿到订单状态后还想查一下发货时间会再次发起 tool_call。循环直到finish_reason stop为止是最稳妥的写法。限制最大轮数是为了防止模型陷入反复调用同一个工具的环路这一点在真实生产环境里非常必要。5.3 运行效果与我的实测心得我拿这个案例跑了不下十轮覆盖了单工具、多工具、参数缺失三种情况。参数缺失这个情况很有意思当你只问“我的订单到哪了”而不给订单号时模型不会硬去调工具而是会主动追问“请提供订单号”。不要担心模型在这种场景下空转它的对话能力会自然接管。反之如果业务上你希望在参数缺失时也能继续可以在工具定义里把order_id设置为可选同时在后端做模糊查“最近订单”。这是产品设计层面的事跟 API 无关但值得你在动手前想清楚。另外我实测下来mistral-large-latest在多工具调用时返回的tool_calls顺序有时不固定所以不要假设第一个工具一定是订单查询。代码里用name精确分发才是安全的。还有就是工具返回中文时务必ensure_asciiFalse再序列化否则模型会收到一串 \uXXXX虽然它能看懂但回复的语气可能会变得非常僵硬。6. 高频问题和排查经验实录6.1 工具调用解析失败、参数为 None 的情况有一次我在别人的代码里看到tool_call.function.arguments直接被当成dict用直接用args[city]运行必然 TypeError。两点经验一是确认你用的 SDK 版本旧版本里arguments的类型在不同接口下有差异先打出来看看是什么类型二是统一写成json.loads(tool_call.function.arguments)不要偷懒。如果你在 Python 端处理完还是报错再盯一眼打印看是不是None。我遇到过模型判定要调用工具但arguments是空字符串的情况多半是参数定义有歧义模型抽不出值这时候去优化参数描述和required字段而不是改代码。6.2 JSON 输出末尾被截断的问题前面提过max_tokens不够时 JSON 会断尾。这个事有个更隐蔽的表现当你用json.loads解析失败错误信息提示“Expecting property name enclosed in double quotes”之类的别急着怀疑模型先检查 content 的末尾是不是像“..., status: 已”这样悬着确认之后把max_tokens上调。我备用过一个简单策略解析失败时自动让模型追加输出“请继续上一个 JSON 输出不要重复已输出内容”。实测有效但更推荐直接把上限调足后面这个补救策略偶尔会引入重复字段。6.3 多工具选择混乱怎么干预工具多了模型选错是最让人无语的比如用户问“下雨了快递会不会延误”模型却调了订单查询工具因为它看到了“快递”两个字。这种问题多半不是模型笨而是你的工具描述不够区分。我自己的经验是描述里要把触发条件写全并且明确排除项。比如订单查询的描述末尾补一句“与本订单号无关的天气、物流政策问题不要使用本工具”天气查询的描述写在“当讨论是否下雨影响出行时使用”两个工具的边界就清楚了。还不行的话就改用tool_choice控制在明确场景下手动指定工具别让模型做选择。6.4 我常用的生产配置清单这里是我做完几个项目后沉淀下来的一套模板参数不敢说放之四海皆准但你从这套起步会少走很多弯路使用场景推荐模型temperaturemax_tokens备注简单意图分类mistral-small-latest0256用 json_object 模式函数调用编排mistral-large-latest0.11024工具描述写细一点多轮多工具调度mistral-large-latest0.12048外层必须加调用轮次上限最终自然语言润色mistral-large-latest0.7512单独一次请求不用 tools这套配置的本质是“分阶段控参数”模型做决策时保守做表达时放开。函数调用负责和系统交互的部分我用低温保证准确到生成面向用户的自然回复时再用常规温度让语气更自然不要让一个温度参数从头用到尾。7. 最后分享几个小实操建议函数调用不是万金油有的小项目只做一句话翻译没必要上 tools。我判断的标准是模型输出需不需要连接到你自己的数据或系统需要才加。如果你的代码里已经开始出现“解析模型输出里的操作指令然后手动执行”这种模式说明你该改成真正的函数调用了。工具定义设计上我一直坚持“小而专”一个工具只做一件事不要让一个工具背上五个可选参数。模型选工具时是根据名称和描述匹配的工具职责越小匹配准确率越高。建议你动手前先花十分钟把业务动作写成一张清单再逐个转成工具定义不要直接在代码里现写。最后一个小技巧在所有工具执行函数入口加一个日志打印记录模型传来的原始参数。这个日志在联调和排查时起大作用。我见过太多人上线后出了“模型调错工具”的问题全靠原始参数日志才发现是描述文本写反了。多打一条 print 浪费不了多少时间省下的排查时间能抵回十倍。
返回列表