
做AI Agent开发的这几年有一个问题被反复问到也贯穿了几乎所有项目从原型到落地的全过程面对LLM一次模型请求到底应该怎么发是直接扔一段自然语言让它返回文本还是把工具清单塞给它让它在里面选又或者在一开始就告诉它“你必须给我吐出一份符合固定Schema的JSON”标题里的“单次模型请求”指的是Agent在执行一个原子步骤时对LLM发起的那一次独立调用。这个问题看起来基础到不值一提但我在好几个项目里都发现不同的请求方式直接决定了下游代码是稳定得让人放心还是间歇性抽风。这几种方式本质上是三类完全不同的接口形态。这篇文章不准备讲“Agent框架怎么搭建”这种大而全的东西只想把单次请求这件事拆开揉碎讲讲三种主流方式各自的原理、适用边界、以及我在实际项目中踩过的坑和最终沉淀下来的选型思路。如果你正在写Agent的编排层或者被模型输出解析搞得焦头烂额这篇应该能给你省不少时间。1. 为什么单次模型请求能决定一个Agent的下限很多刚接触Agent的开发者都有一个错觉既然LLM这么聪明那Agent的核心竞争力肯定在“聪明”上面。但实际做下来你会发现真正决定一个Agent系统稳定性的往往不是模型多聪明而是每一次请求的输出是否“可预期、可解析、可流程化”。调用LLM的方式就是那个把不可控的“聪明”翻译成可控代码行为的关键接口。1.1 从“对话生成器”到“决策循环器”接口思维的转变传统聊天机器人调用LLM本质上是“生成一段好看的文本”。用户问一句模型答一段只要文字通顺、语气自然这个请求就算成功了。但Agent的场景完全不一样它的核心是一个循环感知当前状态决定下一步动作执行动作观察结果再决定下一步。在这个循环里LLM的输出不再是一个终点而是一个被代码消费的中间产物它可能是“调用哪个工具”也可能是“从这段文本里抽取出的结构化参数”还可能是“对当前状态的分类判断”。这就带来一个本质变化模型的输出必须能被程序可靠地解析和分发而不是被用户阅读。如果你用“生成文本”的方式去驱动一个“决策循环”就好像要求一个相声演员同时兼任流程控制语句——他能说但他不知道你什么时候该断句。三种主流请求方式本质上就是三种把大脑输出“降维”成程序输入的语言协议。1.2 单次请求承载的三类任务形态决策、抽取、并联工具在具体项目里我把Agent的单次请求任务归纳成三种形态它们对应了三种请求方式的“用户意图”。第一类是决策型任务输入当前上下文输出一个对后续流程的走向判断。比如判断用户这句话是闲聊还是要下单或者判断当前状态是否满足某个终止条件。这类任务对输出的要求是“少数几个确定的候选值”。第二类是抽取型任务从一段非结构化文本中提取结构化信息比如从邮件里抽取时间、地点、参与人最后转成一个标准的事件对象。第三类是执行型任务模型需要从一堆可用工具中挑选合适的一个或多个并生成对应的调用参数这一步最接近传统程序中“函数分派”的逻辑。有意思的是这三种任务形态并不是互斥的一个真实Agent的同一轮请求里经常同时包含“判断”和“抽取”的成分。后面要聊的三种请求方式刚好能覆盖它们的不同侧重。而你的任务就是根据当前节点更需要“稳定的协议”还是“开放的语义”去选择对应的请求方式。2. 方式一自然语言补全式简单场景下的够用方案先说最传统的方式把对话历史、系统提示词、用户输入拼成一个消息数组发给模型然后拿到一段完整的文本回复。这是大多数开发者最熟悉的一条路径也是最容易糊弄过去的一条因为它的输出“看起来”最自然。2.1 补全式请求的原理与使用边界这种方式的核心原理就是让模型根据已有的上下文做“最合理的续写”。你给它一个系统提示“你是一个意图判断助手只输出买家或者卖家”再把用户问题拼接进去模型会接着这个续写趋势输出“买家”或“卖家”。代码写出来大概是这样的示范结构response client.chat.completions.create( model合适的模型标识, messages[ {role: system, content: 你是一个意图判断助手。只输出字母A或B不要输出其他内容。}, {role: user, content: 用户说我想把闲置的衣服卖出去。请判断意图。} ], temperature0.2, max_tokens20 )这种方式最大的优势是简单、无依赖不需要维护工具Schema也不需要额外的结构化约束。在项目早期或者单点小判断的场景下非常够用比如一个客服机器人需要判断用户的情绪极性或者一个信息录入脚本需要判断一段文本里有没有包含联系方式。只要输出空间极小、判断标准明确补全式请求就是性价比最高的方式。但它的适用边界也特别清晰一旦输出空间是开放式的或者输出结果需要被程序精确消费补全式就开始变得脆弱。模型可能在你要的JSON前后加一句“好的以下是您需要的JSON”也可能突然把键名里的下划线改成驼峰更别说偶尔掺杂的Markdown代码块标记了。2.2 格式漂移问题与重试兜底策略我用补全式请求踩过最典型的坑是让模型输出一段JSON然后直接用json.loads()去解析。本地测试二十次全都正常一上生产环境就偶尔出现解析失败。后来查日志发现同一个模型在过长的系统提示词下会产生“格式漂移”有时多了一个尾逗号有时把布尔值写成了字符串最离谱的一次是把整个JSON放进了Markdown的json代码块里。稳定性和格式解析的矛盾在补全式上基本无解只能靠兜底策略。我的做法是写一个“宽容解析器”先用正则去掉可能的代码块包裹和首尾噪声再尝试标准解析如果失败就提取最长的大括号对再试一次两次都失败才把解析失败信息和原始输出拼回消息列表让模型自己修正一次同时把温度调到0。这套策略能把解析成功率从大概80%拉到99%但代价是多耗时而且增加了代码复杂度。所以我在后面做Agent的时候只要输出需要被程序反序列化就坚决不用裸补全式。3. 方式二函数/工具调用式让模型输出可编程的意图第二种方式是现在Agent开发里最主流、也最值得你花时间研究的一种函数调用式请求。它在技术上的正式叫法是工具调用核心思路是你事先把可用工具的名称、描述、参数Schema告诉模型然后模型在需要时把自己的意图表达成“我要调用哪个工具参数是什么”的结构化数据真正执行动作的还是你的代码。3.1 tool_calls的调用流程与最小示例从请求方的视角看函数调用式请求其实分两步第一步是带着工具清单发起请求模型返回一个“工具调用请求”第二步是你执行代码再把结果作为新的消息传回去让模型继续推进。这里的重点在于第一次请求的返回实际上并没有真正把函数跑出来它只是把“模型决定要做的事”以结构化形式交到你手里。tools [ { type: function, function: { name: query_inventory, description: 查询指定商品的实时库存, parameters: { type: object, properties: { sku_id: {type: string, description: 商品的SKU编号} }, required: [sku_id] } } } ] resp client.chat.completions.create( model合适的模型标识, messages[{role: user, content: 帮我查一下SKU编号为A1001的库存还剩多少}], toolstools, tool_choiceauto ) tool_call resp.choices[0].message.tool_calls[0] print(tool_call.function.name) print(tool_call.function.arguments)这种请求方式之所以能成为主流是因为它把“模型聪明地决定”和“程序可靠地执行”完美切开模型的输出被严格限制在“选择工具 生成参数”这个动作空间里程序只需要根据function.name做分派就能精确控制Agent的行为边界。比起自然语言返回它的输出可解析性、可校验性都有了质的提升。3.2 Schema质量极大影响正确率怎么写才稳函数调用式的效果好坏很大程度不取决于模型而取决于你写的工具Schema。很多人以为Schema只是给模型看的一份“使用说明书”随便写写就行实际上它才是决定模型能不能正确选工具的第一要素。我见过太多因为工具描述含糊导致模型反复选错工具甚至完全拒绝调用的案例。写好Schema有几条我总结出来的实践经验第一参数的description必须写清楚这个参数的业务含义和取值范围而不是只写字段名比如sku_id的描述最好写成“商品在ERP系统中的唯一标识通常以大写字母A开头后跟四位数字”这比写“商品编号”有用得多。第二不要在参数Schema里塞太多可选的“方便字段”每多一个可选字段模型就需要多做一次决策正确率就会下降一点。第三如果某些参数之间存在依赖关系尽量在描述里用一句话说清楚比尝试在Schema层面做嵌套约束要可靠得多。还有一个容易忽略的细节工具的数量不要一上来就堆四十个。模型在选择工具时工具列表越长注意力分散得越厉害尤其当工具之间的功能有重叠时选错率会显著上升。我通常会先在代码层做工具归类把“查询类工具也分成库存、物流、价格”合并成一个“查询综合信息根据参数路由到子查询”让模型只做一次粗粒度选择。3.3 三个高频踩坑点并行调用、重试消息、空响应判空函数调用式在实战中有一个特别容易让人误解的能力并行调用。很多模型支持在一次响应里返回多个tool_calls这本身是好事但它会带来一个隐蔽问题——并行调用的多个工具之间如果有依赖关系比如“先查库存再下订单”模型并不会帮你保证顺序。它只是把所有它觉得该做的事一次性列出来真正的顺序和依赖判断还得你在编排层去做。第二个坑是重试时的消息组装。如果你对一个函数调用结果不满意想让模型重新生成很多人会把tool_call原样塞回消息列表就完事了。但正确的做法是你先要追加一条tool角色消息内容是模型上一次调用的返回结果然后才能发起下一次请求。如果你漏掉了这条tool消息模型会完全丢失“刚才那次调用发生了”的上下文重试出来的结果往往答非所问。第三个坑是空响应判空。虽然函数调用式请求一般会返回tool_calls但模型仍然有可能在极端情况下不选择任何工具直接返回一段自然语言说“抱歉我无法处理”。这时候你的代码如果没有对tool_calls为空做兜底直接去索引[0]就会抛异常。我习惯在拿到响应后的第一件事就是判断如果tool_calls为空且消息内容为普通文本就直接把这个文本返回给上层而不是强行让Agent“继续走流程”。4. 方式三结构化输出约束式给模型输出上“格式锁”第三种方式也是目前模型服务端支持度越来越高的一种结构化输出约束式请求。它的核心是直接在请求时声明“响应必须满足某个JSON Schema”模型在生成过程中就会受到严格约束不会输出任何不符合格式的内容。它和前面两种最大的区别是更严格、更接近传统API协议几乎完全牺牲了文本自由度。4.1 用JSON Schema锁死输出协议结构化输出式的运作方式非常直白你定义一个JSON Schema把它传给模型服务端然后模型生成的所有token都会在这个Schema的框架内进行采样。比如你想让模型返回一个包含status和reason字段的JSON对象其中status只能取成功与失败两个值reason是字符串你的请求可以写成这样resp client.chat.completions.create( model合适的模型标识, messages[{role: user, content: 判断这句话的情感倾向今天天气真不错}], response_format{ type: json_schema, json_schema: { name: sentiment_result, schema: { type: object, properties: { status: {type: string, enum: [成功, 失败]}, reason: {type: string} }, required: [status, reason] } } } )请求发出去之后你拿到的响应就一定是合法JSON且一定包含status和reason两个字段status的值一定会落在预先定义的枚举里。这意味着什么意味着你的代码可以彻底扔掉那些“宽容解析”“正则剥壳”“失败重试”等各种丑陋的防御逻辑直接信任解析结果。我最早把这种方式用在一个Agent和外部系统对接的接口上从那以后基本再没有因为JSON格式问题半夜爬起来过。从原理上看结构化输出之所以能做到“绝对合规”是因为它在采样阶段就做了约束而不是生成后再去校验修改。相当于你请了一个厨师他炒菜的时候每一勺盐都不能偏离固定配方成品自然不需要返工。4.2 严格模式的好处与代价但结构化输出也不是银弹。它最直接的代价是灵活性下降。当你把输出锁定在一个Schema里模型就失去了“超纲回答”的能力一旦输入的内容超出了你的Schema设计范围它只能硬着头皮把内容塞进有限的字段里很多时候塞出来的结果语义是错的。比如我只定义了status和reason两个字段用户却问了一个跟情感判断无关的问题模型会在status里写“失败”在reason里写“该问题不在支持范围内”确实合规但对用户来说毫无价值。另一个代价是对模型和Schema设计的双重要求。结构化输出在很多在线模型上有服务端约束支持的模型数量有限而且如果你的Schema嵌套层级太深、数组里套对象、对象里再套数组部分模型会直接报错或者生成速度明显变慢。我在一个实际项目里就遇到过一个五六层嵌套的复杂Schema模型每次生成都要耗时接近普通请求的三倍因为约束条件下的采样路径变长且选择空间变小了。所以我的判断是结构化输出最适合的地方是Agent和数据层/服务协议之间的“最后一公里”而不是Agent内部所有环节的通吃方案。换句话说当你在实现“把模型输出转成下游系统能接受的规范格式”这一步时结构化输出是无可替代的但在Agent做自由判断、生成想法的时候强行上Schema约束反而会适得其反。5. 三种方式的选型逻辑与混合使用聊完三种方式各自的原理和坑接下来是这个话题最有价值的部分到底该怎么选以及怎么把它们组合起来用。我在多个Agent项目里一次次调试、回滚、重构之后基本沉淀出了一套自己的选型标准和混合架构。5.1 一套选型标准与对照表很多人一上来就问“哪个方式最好”这其实是个伪命题它们的目标根本不是同一件事。我从四个维度做了个横向对照对比维度自然语言补全式函数调用式结构化输出式输出确定性低可能格式漂移中高意图可结构化高格式严格受控开发与解析成本低但兜底逻辑复杂中需维护Schema中高Schema设计工作量最大适合的任务类型小范围分类、情感判断工具选择、动作决策固定协议数据交换、下游对接模型兼容性最高较高较低依赖服务端能力Agent内部节点适用性早期原型、低频判断核心决策环节数据出境/入境的守门环节我的选型标准很简单如果这个节点的输出只是局部判断不需要被下游精细化消费用补全式如果这个节点的输出将驱动Agent执行动作用函数调用式如果这个节点的输出会写入外部系统或者参与到严格的多方协议中用结构化输出式。这个判断标准基本没有失手过。5.2 混合请求示例用tool搭建算子用结构化输出接服务协议在实际系统里我习惯把这三种方式组合成两个明显分层。内部Agent的决策循环全部走函数调用式模型选工具、生成参数、代码执行形成内部闭环。而这个闭环一旦需要把结果输出给外部业务系统就在出口处加一层结构化输出式请求把内部那个“语义化、不规整”的中间结果重新整理成符合外部接口规范的JSON。这样既保住了Agent内部决策的灵活性也保住了对外交互的协议稳定性。比如一个模拟的订单履约Agent它内部会通过函数调用式去调用“查询库存”“计算运费”“生成订单号”这些算子每个算子返回的都是一段灵活的JSON或文本。当Agent最终需要“创建正式订单”并把结果提交到订单中心时我不会直接把它内部的临时JSON转过去而是发一次结构化输出请求Schema直接按订单中心的接口规范来定义模型会把所有内部信息整理成一份字段完整、类型正确的正式订单报文。这一层转换让Agent的“杂乱内部对话”和“严谨外部接口”之间有了一个干净的防火墙。混合使用还有一个隐藏的好处降本。大模型调用是按token计费的结构化输出请求如果每次都带着复杂的Schema去处理和校验开销并不小。所以我在Agent内部尽量用补全式做低成本的简单判断用函数调用式做核心决策只在真正需要严格协议的出口才动用结构化输出。三种方式各司其职整体成本比“全链路都用结构化输出”低很多稳定性也不差。关于这三种方式我最后想说的体会如果把Agent比作一间公司自然语言补全式像“口头沟通”函数调用式像“各部门提交工单”结构化输出式像“法律合同文本”。三者在不同场合各有不可替代的价值关键是你得知道你现在正在处理的是哪种沟通。我自己在不同项目里反复调整过很多次参数、Schema和兜底逻辑最后得出的体会是模型的能力当然重要但你对“请求方式”的选择才是决定Agent系统上限和下限的真正分界线。下限由解析稳定性决定上限由决策灵活度决定而两种特性天然有张力要处理好就只能在这三种请求方式之间做清醒的取舍与组合。最后分享一个非常具体的实操建议在你开始写Agent编排代码之前先给项目里每一个需要调用模型的节点画一张小表标明这个节点的输入是什么、输出要给谁消费、允许的输出空间有多大。然后照着这三种方式做一次分类你会发现整个系统的架构瞬间清晰了该用哪种方式也基本一目了然。这一步前置思考花的时间后面一定能从调试和兜底代码里省回来。