你写Agent的时候,可能早早就发现了:整个系统的骨架是一长串模型调用,但真正决定Agent“聪明不聪明、稳不稳”的,往往是其中某一次请求怎么发出去。同样是单次模型请求,直接要一段文本、要一份严格JSON、还是让模型自己选出该调用哪个工具,这三条路背后的设计和坑完全不同。这篇文章就围绕AI Agent里单次模型请求的三种主流方式展开,拆开讲清楚它们各自适合什么场景、代码怎么组织、参数怎么定,以及在调试时最容易踩的坑。不管你是刚接触Agent开发,还是在为已有系统优化稳定性,这篇都值得花十分钟读完。
先说一个我自己的体会:很多人把模型请求想得太简单,以为“把prompt发过去,拿到返回,完事”。但Agent里的单次请求不是这么回事——它承载着上下文、决策和动作信号。同样的意图,你用不同方式去请求,模型返回的可能是纯文字、结构化字段,也可能是一个“我想调用天气工具”的动作描述。这三种返回形态,直接决定你接下去要写多少解析代码、做多少兜底逻辑。对工程来说,这几乎是Agent稳定性的分水岭。
1. 内容整体设计与思路拆解
1.1 单次模型请求在Agent架构里的位置
先给一个框架。一个Agent系统无论多复杂,工作主循环基本可以抽象成四个步骤:理解当前状态、组装上下文、调用模型、执行决策。这里的“调用模型”指的就是单次模型请求,而你用哪种请求方式,决定了系统能在一次调用里拿到多高浓度的有效信息。
打个比方,你到一家餐厅吃饭,跟服务员说“来一份招牌菜”和“来一份不要香菜、少油、微辣的土豆丝”,得到的结果完全不同。对服务员(模型)来说,“招牌菜”是自由发挥;而后面那种点法,相当于你给了约束。Agent工程里的单次请求,就是一次“下单”:方式不同,服务员的自由度不同,你后续处理“上菜结果”的复杂度也不同。
所以,单次请求并不是“调一次API”这么简单,而是一次信息交互协议的设计。你要决定:让模型自由生成、让模型按格式吐数据、还是让模型去调用外部工具。这个决定会在Agent的稳定性、解析成本、容错率上放大出来。
1.2 三种方式的本质差异与选择依据
三种主流方式分别是:
- 直接生成文本:把系统提示和历史消息打包发给模型,模型返回一段自然语言。结构最弱,灵活度最高。
- 结构化输出:在请求中指定输出格式(通常是一段JSON Schema),模型按要求返回可解析的JSON数据。牺牲部分灵活性,换取程序可读性。
- 函数调用:在请求中声明一组可用函数格式,模型自行决定需要调用哪个函数,并返回对应的参数。这是Agent“行动力”的基础。
这三者本质都是文本生成,差异在于你施加强约束的位置不同。直接生成不约束格式;结构化输出约束的是“响应结构”;函数调用约束的是“动作选择”,让模型输出“动作指令”而不是普通句子。
选择依据也不复杂,关键看这一轮模型调用的“目的”是什么。
| 使用方式 | 核心目的 | 典型场景 | 主要成本 |
|---|---|---|---|
| 直接生成文本 | 获得表达灵活的回复 | 小结、润色、角色扮演 | 后续需要自己解析意图 |
| 结构化输出 | 获得程序可解析的数据 | 意图识别、信息抽取、路由分发 | 字段设计需要仔细考量 |
| 函数调用 | 让模型触发动作 | 查询天气、查订单、调用内部接口 | 需要处理工具参数与执行结果 |
我自己在项目里经常收到类似“你这个Agent怎么连个简单意图都识别不准”的问题。去查之后,大部分情况根本不是模型能力不够,而是请求方式选错了。比如一个只需要“判断走哪个分支”的地方,你偏让模型自由发挥,然后再用关键词去匹配返回文本,效果自然时好时坏。把这一处改成结构化输出后,准确率和排查速度都立竿见影。这也能说明为什么“单次请求方式”值得认真琢磨。
2. 核心细节解析与实操要点
2.1 方式一:直接生成文本的请求细节
直接生成文本是最基础的请求方式。你只要准备一段消息数组,发给模型接口,它就会基于这段上下文返回自然语言。这里的关键词是“消息数组”,因为它决定模型看到的上下文是否清晰。
一个标准请求通常包含三类消息:
- system:给模型设定角色、任务边界、输出风格。这部分的写法直接影响后续所有回复的质量。
- user:本次用户输入,通常是任务内容或问题。
- assistant:历史对话中模型已经生成的回复。多轮场景下必须带上,否则模型没有上下文连贯性。
发出单次请求时,有一个常见误区:一次性把几百条历史记录全塞进去。上下文越长,花费越高,响应越慢,模型还容易被无关信息干扰。从工程角度看,单次请求的上下文应该在“够用”的前提下尽量短,必要时要对历史消息做截断或摘要。
再看参数。temperature是最值得说的一项。你想让模型做稳定的决策时,把temperature调到 0 或 0.2,能显著减少随机性;如果是生成文案、起名字这类偏创意任务,调高到 0.7 以上效果更自然。这个参数不是越高越好,要看任务属性。另一个常用参数是max_tokens,它限制本次响应最大token数。如果模型的思考过程较长,你把它设太短,响应会被硬截断,输出可能是一个半成品句子。我的习惯是给生成类任务留足余量,一般设成预估结果的1.5倍。
直接生成文本的代码,各家API大同小异。以常见的客户为例,你会写类似这样的逻辑:
def request_text(system_prompt, user_message): messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}, ] response = client.chat.completions.create( model="your-model-name", messages=messages, temperature=0.3, max_tokens=500, ) return response.choices[0].message.content这段代码本身没有惊喜,惊喜在后头:如果你在Agent里直接拿这段返回文本去做“下一步动作判断”,麻烦就来了。模型可能回答“好的,我来帮你查一下天气”,也可能回答“查询天气需要调用工具”,两种表达都自然,但你的程序不好区分。所以在使用这种最基础的方式时,我强烈建议你把它限定在“最终生成的展示文本”上,而不是拿它做中间决策。
2.2 方式二:结构化输出怎么保证返回可解析
结构化输出的核心是给模型一个严格约束,让它只返回你能直接解析的数据。它的价值可以这样理解:你用一段JSON描述“我要这样的返回”,模型就会按这个骨架生成,程序拿到结果后能直接改造成数据结构,不用再去文本里找线索。
实现结构化输出主要有两条路,具体取决于你的API服务能力:
- 原生JSON模式:很多主流大模型接口支持类似
response_format的参数,你在请求里声明“以JSON格式返回”,平台会尽力保证输出是合法JSON。 - 提示词约束:如果接口不支持原生JSON模式,或者返回不够稳,你就在system提示词里显式说明“只返回JSON,不要多解释”,并把期望的结构写进去。
单次请求用结构化输出的典型场景是意图路由。比如客服系统里,模型要判断用户问题属于“查订单”“咨询商品”还是“转人工”,你就可以这样设计:
response = client.chat.completions.create( model="your-model-name", messages=[ {"role": "system", "content": "你是意图识别模块,只输出JSON,不要输出其他文字。"}, {"role": "user", "content": user_text}, ], response_format={"type": "json_object"}, temperature=0, ) data = json.loads(response.choices[0].message.content) # data: {"intent": "check_order", "keywords": ["订单"], "confidence": 0.93}这里有几个坑必须提前说。
第一,json.loads不一定百分百成功。如果模型返回多了个markdown代码块标记(比如```json),解析就会报错。我建议在解析前先做一层清洗,把代码块标记剥掉。
content = response.choices[0].message.content content = content.strip() if content.startswith("```"): # 去掉 ```json 或 ``` 前缀,再去掉结尾的 ``` lines = content.splitlines() content = "\n".join(lines[1:] if lines[0].startswith("```") else lines) if content.endswith("```"): content = content[:-3].rstrip() data = json.loads(content)第二,字段设计越简单越好。一次请求里不要逼模型输出一个十几层的嵌套JSON,嵌套深了模型容易在某层犯迷糊。我的经验是,结构化输出只保留你最需要的那几个字段,其余让模型在后续子请求里再去生成。
第三,temperature别调太高。结构化输出本质上要求模型“按规矩办事”,温度高了它就喜欢发挥,格式违规的概率显著上升。路由、抽取类场景我基本固定为0。
2.3 方式三:函数调用让模型自己决定调什么工具
函数调用是Agent系统里最值得仔细打磨的一种请求方式。它的机制可以这样理解:你在请求里放一份“可用函数清单”,模型读完之后,根据用户问题决定“目前应该调用哪一个”,并以结构化数据返回函数名和参数。注意,模型并不真正执行函数,它只返回“我想调这个函数,参数是这些”,真正执行的还是你自己的代码。
还是举客服的例子。假设用户问“今天南京天气怎么样”,你不需要让模型直接生成一段话,而是希望他先调用一个天气查询函数。于是你在请求里声明这个函数:
tools = [ { "type": "function", "function": { "name": "query_weather", "description": "查询指定城市当前天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如北京、南京"}, "date": {"type": "string", "description": "日期,格式YYYY-MM-DD"} }, "required": ["city"] } } } ] response = client.chat.completions.create( model="your-model-name", messages=[ {"role": "system", "content": "你是智能助手,根据用户问题自主选择合适的工具。"}, {"role": "user", "content": "今天南京天气怎么样?"} ], tools=tools, tool_choice="auto", )返回结果中会有一个tool_calls数组,里面是该调用的函数名和参数。你的代码要判断message.tool_calls是否存在,存在则逐个解析执行:
message = response.choices[0].message if message.tool_calls: for tool_call in message.tool_calls: fn_name = tool_call.function.name args = json.loads(tool_call.function.arguments) # 根据 fn_name 分发到对应的执行函数 result = run_tool(fn_name, args)函数调用比结构化输出更进一步,它不只是让模型“输出一个意图”,而是让模型“输出了一个行动指令”。这对Agent的意义很大:模型自己决定要不要用工具、用哪个工具、用什么参数。这也正是“让模型连接外部世界”的关键环节。
实操中有三个细节值得留意。
第一,函数描述决定了模型能否正确选工具。描述要写清楚什么时候用、什么时候不用。比如query_weather的描述如果写成“查询天气”,模型遇到“今天冷不冷”这种问题可能就不会调用它;但如果描述改成“查询指定城市指定日期的天气,包括温度、风力、降水信息,影响穿衣建议”,模型的判断准确率会明显提升。
第二,tool_choice参数可以控制模型用工具的“自由度”。默认auto表示模型自己决定用不用;如果你希望这次请求强制走某一个工具,可以设为{"type": "function", "function": {"name": "query_weather"}}。有些场景下模型明明有工具可用,却偏要直接回答,这时候强制指定就能解决。
第三,多个工具同时返回时要循环处理。模型可能一次性请求多个工具,比如“查天气”和“查城市邮编”,返回的tool_calls里就包含多个元素。如果你只处理第一个,后面的就被丢掉了,Agent行为会不完整。
3. 实操过程与核心环节实现
3.1 一个示例:为Agent实现智能客服路由
说了这么多原理,不如完整跑一遍一个具体场景。假设我在做一个“智能客服辅助系统”,需求是:用户输入任何一句话,Agent在一个单次模型请求里完成两件事——判断用户意图(问商品信息/查订单/转人工),如果是“查订单”,还要顺便抽取订单号。用一次请求搞定,结果以结构化数据返回。
这个例子我把结构化输出和工具调用的思想都融入进去。首先定义订单查询工具:
tools = [ { "type": "function", "function": { "name": "query_order", "description": "查询用户订单的最新状态。仅当用户主动提供订单号或明确表达查订单需求时调用。", "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "订单编号,例如ORD20250101"}, }, "required": ["order_id"] } } } ]然后写一个处理函数。为了清晰,我直接模拟一下每次请求的返回处理流程。
def route_and_handle(user_input): messages = [ {"role": "system", "content": "你是一个客服路由智能体。根据用户输入判断意图,必要时调用工具。不要自行编造订单状态。"}, {"role": "user", "content": user_input}, ] response = client.chat.completions.create( model="your-model-name", messages=messages, tools=tools, tool_choice="auto", temperature=0, max_tokens=300, ) message = response.choices[0].message # 分支一:模型选择了工具调用 if message.tool_calls: tool_call = message.tool_calls[0] if tool_call.function.name == "query_order": args = json.loads(tool_call.function.arguments) # 这里是你自己的工具执行逻辑 order_info = query_order_service(args["order_id"]) # 工具执行后,通常需要把结果送回模型再生成面向用户的回复 return compose_final_answer(order_info) # 分支二:模型认为不需要工具,直接输出语义判断 return message.content这段代码看起来短,但里面的分支结构是最关键的。你要知道单次模型请求可能返回两种形态:一是“调用了工具”,二是“直接给出文本”。两个分支都要处理。在实际项目里,我见过不少团队只处理了文本分支,结果模型每次返回工具调用时系统就崩了。所以“分支判断”是函数调用模式下的第一道防线。
3.2 参数选择与关键权衡
上面的例子有几个参数是刻意选的,这里把思路说透。
temperature=0:路由决策这类任务几乎没有创意空间,我需要模型每次都做出最确定的判断,所以直接归零。
max_tokens=300:因为响应要么是工具参数JSON,要么是一句简短意图,300足够。设太大反而会让模型有空间“发挥废话”。
tool_choice="auto":用户可能是在问商品信息,也可能是在查订单。用auto就是让模型自己判断哪个场景该调用工具。如果这里强制指定了query_order,模型遇到“你们有什么新品”也会去查订单,显然不合理。
messages的组织顺序也值得注意:system在前,user在后,中间若有历史对话则按时间顺序排列。这个顺序不能乱,否则模型对上下文的感知会错位。
再说一个常见但容易被忽略的点:工具调用的结果不能“一次性用完就扔”。完整的Agent单轮交互是:请求(带工具)→ 模型返回工具调用 → 你执行工具 → 把工具结果作为一条新消息发回去 → 模型最终生成面向用户的回答。很多新手只在第一步停下来,以为拿到工具参数就完事了,结果用户端什么都看不到。我建议在代码里把“工具执行后回填结果”当作一个固定环节:
# 工具执行结果回填 messages.append(message) # 把模型返回的 tool_calls 加回去 messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(order_info, ensure_ascii=False), }) # 再请求一次,让模型基于真实工具结果来回答 final_response = client.chat.completions.create( model="your-model-name", messages=messages, temperature=0.3, max_tokens=300, ) user_facing_answer = final_response.choices[0].message.content这一步很多人没写,导致Agent“查了跟没查一样”。单次模型请求在这里其实扩展成了“请求—执行—回填—再请求”的小循环,但这个循环里的每一次请求仍然是标准的单次请求,你仍然可以用上面三种方式来控制。
3.3 请求返回字段速查
调试Agent时,你会反复面对response.choices[0].message这个对象。我整理了一个快速判断表,顺着这个表就能很快定位到自己的代码该走哪个分支:
| 返回情况 | 典型特征 | 你的处理 |
|---|---|---|
| 模型想调用工具 | message.tool_calls不为空 | 解析函数名与参数,执行工具 |
| 模型直接回答 | message.content非空且tool_calls为空 | 清理文本,解析或展示给用户 |
| 响应被截断 | finish_reason为length | 增大max_tokens,或精简上下文 |
| 内容涉及安全过滤 | finish_reason为content_filter或等效字段 | 记录日志,用兜底话术返回 |
| JSON解析失败 | json.loads抛异常 | 清洗代码块标记,重试一次 |
这张表值得打印出来贴工位上。我在项目里踩过的坑,十有八九都能在这张表里找到对应项。尤其是finish_reason=length这个情况,很多人第一反应是“模型能力不行”,实际只是你给的最大token太少,请求都没说完就被掐断了。
4. 常见问题与排查技巧实录
4.1 高频问题速查
下面这些问题都是我在实际开发中反复碰到过的,身边同事也踩过同样的。我按“现象—原因—排查方法”整理成一张速查表。
| 常见问题 | 根本原因 | 排查与解决办法 |
|---|---|---|
| 工具函数名对不上,模型总是不调用 | 函数描述含糊,模型不知道何时用它 | 重写description,明确触发条件和不触发条件 |
| JSON输出带markdown标记,解析失败 | 未启用原生JSON模式,模型习惯性包代码块 | 解析前做markdown剥离,或改用response_format |
| 工具调用有参数,但参数少传了必填项 | Schema里required没写清,模型自由发挥 | 在parameters.required里把必填字段全部列出 |
| 响应永远卡在一个工具上,不换别的 | tool_choice被强制指定,或描述误导 | 改为auto,并检查各工具描述是否有歧义 |
| 模型回答“我不确定订单状态” | 工具执行结果没回填给模型,模型在瞎猜 | 把工具结果作为tool类型消息追加到上下文中再请求 |
| 每次请求都很慢 | 上下文太长,或传入了无用的历史记录 | 做消息裁剪,只保留最近N轮与当前任务相关的消息 |
| 天气、订单等结果对不上 | 工具参数里城市名、日期被模型解析错了 | 在参数描述中补充示例,并在执行工具前做规则校验 |
排查这类问题,我建议先看日志。每次请求最好都记下model、messages条数、finish_reason、tool_calls内容、耗时这几个字段。没有日志的Agent调试就像闭着眼拆电路,纯靠猜。有了日志,很多问题一眼就能看到是哪个环节断了。
4.2 三个容易忽略但特别影响体验的细节
第一,单次请求里的“上下文一致性”容易被忽略。比如你在system里要求模型“用中文回答”,但你在工具结果回填时传了一段英文数据,模型可能就顺着英文输出了。工具回填内容最好也经过一次清洗或提示词约束,否则模型输出语言会跟着数据乱跑。
第二,工具调用参数要做业务侧校验。模型生成的参数是概率性的,不是100%可靠。比如order_id这种字段,模型可能把用户说的“单号ORD20250101”解析成“ORD2025010”,少了尾号。你不能直接把参数交给订单系统执行,要先做正则校验、长度检查,不合格就走追问流程。很多线上事故都是少了这道校验。
第三,重试策略要有盖度。单次模型请求会超时,会返回503,会因并发限制报错。你以为“模型返回错了”,实际上可能是上游服务不稳定。我常用的策略是:对幂等请求做两次重试,指数退避;对不可重试的请求(比如已经执行完工具,只差最终回答)直接降级到预设话术,而不是死等模型。Agent工程里的稳定性,往往不是靠单次请求调得多好,而是靠这层兜底够不够厚。
4.3 三种方式叠加使用的工程建议
很多项目其实不是只用一种方式,而是一个Agent里混合使用。我自己比较常用的一种组合方式是:
- 用函数调用让模型决定“要不要调用外部动作”;
- 用结构化输出让模型返回“路由决策和关键字段”;
- 用直接文本生成做“面向用户的最终表达”。
这三个各管一段,看起来代码量大,但每段职责单一,调试定位都很方便。相反,如果只图省事,把意图判断、工具选择、用户回复都塞进一次自由文本请求里,短期能跑通,后期每改一个需求都要重新解析规则,维护成本会高得离谱。
我个人的倾向是:能用结构化约束的地方,绝不靠自由发挥。模型生成的自然语言再通顺,也不是稳定的程序接口。每一处“让模型自由发挥”的决策,都应该问自己一句:这个地方我真的需要它自由发挥吗?如果答案是否定的,就该在请求层加约束。
最后再分享一个小习惯:我在代码里会单独维护一个“请求方式记录”模块,每次调用模型时把用了哪一种方式、返回是什么样的形态、耗时多少全部打点。Agent功能调久了,回头翻这些记录,能非常清晰地看到瓶颈在哪里。这个习惯帮我省下过好几次化好几个小时找不到的线上问题。如果你现在正在写Agent,无论规模大小,都建议从第一次模型请求开始就把这条日志链建起来。后面你会感谢自己的。