AI Agent单次模型请求:从裸调API到Function Calling再到框架封装

发布时间:2026/10/13 11:43:05
AI Agent单次模型请求:从裸调API到Function Calling再到框架封装
做AI Agent开发绕不开一个最基础也最容易被忽略的动作往模型服务商发一次请求。很多人以为这就是拼一个prompt、调一个接口的事可真到了Agent场景里“单次模型请求”其实有讲究。从最原始的裸调API到让模型自己决定调用工具再到完全交给Agent框架封装三种主流方式分别代表了从“让请求能通”到“让请求能干活”的进化路径。这篇文章我会用一套真实跑过的最小示例把三条路都拆开讲明白说清楚各自的原理、适用场景和容易踩的坑。适合刚接触Agent开发的读者也想给已经在写业务代码但一直靠“复制粘贴调接口”的老手做个系统梳理。1. 先弄明白Agent里的“单次模型请求”和普通接口调用差在哪1.1 普通聊天请求的底层逻辑一个标准的模型请求本质上就是把你准备好的消息数组发给模型服务商的大模型模型根据已有的训练知识和请求里的内容生成一段补全文本返回给你。这个过程很短单次HTTP请求就能完成。消息数组通常包含system角色用来设定人设和约束user角色用来放用户输入assistant角色用来放历史回复。模型本身没有记忆所有上下文都得靠请求里的消息数组带过去。这也是为什么很多人刚开始写Agent时会觉得困惑我在本地明明定义了一个ask_model()函数每次调用都传了同样的系统提示词可模型的表现就是不稳定。原因很简单模型请求是“无状态”的真正有状态的是你两端拼接消息的方式。单次模型请求的精髓就在于你把“状态”压缩进了这一次请求的输入里。1.2 Agent场景下单次请求要额外解决的三件事Agent和普通聊天应用最大的区别是Agent不仅要“说话”还要“做事”。一个Agent要完成用户交代的任务比如查天气、算价格、查库存通常需要在对话过程中调用外部工具。想要在单次模型请求里实现这种能力至少得解决三件事第一把工具说明书传给模型。模型本身不知道你有那些函数你必须把每个函数的名字、参数、用途用标准格式描述出来塞进请求。第二让模型返回“要不要调工具”的决定。如果模型判断需要调用某个函数它不会直接去执行而是返回一个结构化的调用指令告诉你该调哪个函数、参数是什么。第三把工具执行结果接回来再拼接成新的上下文继续对话。这三种需求正好对应了三种主流实现路径。裸调API只能解决最基础的“把请求发出去”函数调用把工具能力加进了单次请求框架封装则把前面这套繁琐的拼装、解析、上下文管理全部打包。1.3 三种方式本质上是在不同抽象层做取舍直接调用模型服务商接口是控制力最强但体力活最多的方式函数调用通过请求里多塞一个tools字段让模型能输出结构化指令是Agent场景里性价比最高的方式Agent框架则把函数调用以及后续的循环逻辑全封装起来开发效率最高但黑盒风险也最大。为了让你心里有个整体印象我先把三者的差异列成一张表。对比维度方式一裸调API方式二Function Calling方式三框架封装请求形态标准Chat消息请求标准请求外加tools声明框架帮你组装底层请求对工具的支持不支持只能自己拼prompt原生支持结构化工具指令支持但格式由框架决定上手速度快知道HTTP就能跑中等需要理解tool schema最快但理解深度有限调试难度低请求和响应都透明中要处理tool_calls解析高出问题得翻框架源码适合阶段原型验证、学习原理真实Agent功能开发团队快速交付、迭代控制力最强强弱被框架约束选哪条路完全取决于你处在什么阶段。我见过不少团队一上来就用框架结果函数调用返回格式稍微不标准排查问题从业务代码一路追到框架底层非常痛苦。反过来也有不少人一直停留在裸调API手动拼prompt导致Agent的工具调用行为极不稳定。理解了三种方式的边界后面的路就好选了。2. 第一种方式裸调模型API先让请求跑通2.1 Chat接口的基本消息结构裸调API是最朴素的方式代码量最少但该懂的协议细节一个都不能少。现在绝大多数主流模型服务商都提供了兼容格式的Chat接口路径一般是/v1/chat/completions。请求体里的核心字段有三个model指定用哪个模型messages放对话历史temperature和max_tokens等控制生成行为。一个最基础的请求体长这样{ model: your-model-name, messages: [ {role: system, content: 你是一个智能助理回答尽量简洁。}, {role: user, content: 北京今天天气怎么样} ], temperature: 0.7, max_tokens: 500 }注意消息数组里出现了三种角色system负责设基调user负责输入后续多轮对话里还会出现assistant用来告诉模型“上一次你是这么答的”。模型服务商就是靠着这套消息数组来理解上下文的任何一轮对话漏掉assistant的历史回复模型就会失忆。2.2 用通用HTTP库发一次请求不想引入一堆官方SDK依赖时直接用标准库里的requests就能跑通。下面的示例使用环境变量存储密钥没有把任何一家服务商的专属SDK绑死import os import requests def chat_once(messages, temperature0.7): resp requests.post( https://api.example.com/v1/chat/completions, headers{ Authorization: fBearer {os.environ[MODEL_API_KEY]}, Content-Type: application/json, }, json{ model: your-model-name, messages: messages, temperature: temperature, }, timeout60, ) resp.raise_for_status() data resp.json() return data[choices][0][message][content] print(chat_once([ {role: system, content: 你是一个只回答天气相关问题的助理。}, {role: user, content: 北京今天热吗} ]))这个函数虽然只有十几行却是后面所有Agent请求的地基。响应解析时最核心的取值路径是choices[0].message.content。有些接口还带了usage字段能告诉你这次请求消耗了多少token调试成本时很有用。2.3 影响返回质量的关键参数裸调阶段最容易忽略的参数有三个。temperature控制随机性做分类、抽取这类确定性任务时我一般调到0.2以下做创意生成才调到0.7以上。max_tokens限制返回长度设置太短模型会被截断太长则浪费资源和时间。还有一个容易被忽略的top_p它和temperature共同影响采样的多样性实际项目里建议固定其中一个不要同时猛调。有次我在做Agent需要模型从用户话里提取结构化日期temperature设了0.9结果模型每次给的日期格式都不一样。后来把temperature降到0.1稳定性立马上来了。这就是参数对单次请求质量的直接影响。2.4 这种方式的局限在哪里裸调API的问题在于模型只知道“说话”不知道“动手”。你想让它根据用户输入判断该调用哪个工具只能把所有工具说明用自然语言塞进system消息里然后寄希望于模型自己能输出“我现在调用get_weather(北京)”再去正则匹配或者逐行解析这段文本。这在简单demo里勉强可行一旦工具有七八个参数一复杂模型输出就混乱了。所以我把裸调API定位为“初始跑通”方案。它适合验证模型能力、理解接口协议以及业务还没定型时快速试错。真要开发Agent就得往第二种方式上走。3. 第二种方式Function Calling让单次请求带回“工具调用指令”3.1 为什么Agent不能只靠提示词想让Agent自动调用工具最原始的做法是把工具介绍写在prompt里让模型返回特定格式文本再自己解析。我尝试过类似“请输出JSON格式为{tool: xxx, params: xxx}”的写法效果很快翻车。原因在于模型不是程序它对字符串格式的理解不稳定只要多一个空格、少一个引号解析就崩了而且你不知道崩在哪。Function Calling工具调用解决的就是这个痛点。它在协议层面增加了一个tools字段把每个工具的名字、描述、参数格式用结构化schema声明好。模型收到请求后如果判断需要调用某个工具会在响应的message里返回一个tool_calls数组数组中每一项都包含function.name和function.arguments。这个返回是模型专项训练的产物格式规范性远高于让模型“生成JSON字符串”。3.2 tools参数怎么定义tools参数本质上就是一个数组每个元素描述一个工具。以下是一个天气工具的schema示例{ type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称比如北京 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位 } }, required: [city] } } }这个结构并不是我自创的而是当前业界模型服务商普遍采用的协议。最关键的地方有两点description写得越清楚模型选对的概率越高parameters必须按JSON Schema来描述required字段控制哪些参数是必填的。3.3 响应里的tool_calls如何解析发送带tools的请求后模型响应不再是单一的content字符串而是可能多出一个tool_calls字段。这时候如果你还是用老代码去取choices[0].message.content拿到的基本是空值。正确的解析方式是先在消息对象里判断是否存在tool_calls。示例代码如下def parse_tool_calls(message): if not message.get(tool_calls): return [] parsed [] for call in message[tool_calls]: # arguments此时是JSON字符串需要二次解析 import json arguments json.loads(call[function].get(arguments, {})) parsed.append({ id: call[id], name: call[function][name], arguments: arguments, }) return parsed这里最容易出错的是arguments字段。它在接口响应里是一段JSON字符串不是JSON对象必须先json.loads才能真正拿到字典。很多初学Agent的朋友在这一步直接当成字典用结果一执行就报错。3.4 本地执行工具后如何把结果喂回模型模型返回了tool_calls并不代表它执行了工具真正执行的人是你自己的代码。你要根据name找到本地对应的Python函数传参执行再把结果以tool角色的消息追加到消息数组里继续发起一次模型请求。注意这个过程虽然拆成了两次请求但第一次请求的语义依然是“单次模型请求”它让模型交出了调用工具的意图第二次请求则是基于工具结果生成最终答复。def handle_tool_call(messages, tool_call): tool_name tool_call[name] args tool_call[arguments] if tool_name get_weather: result get_weather(args[city], args.get(unit, celsius)) else: result f未知工具: {tool_name} messages.append({ role: tool, tool_call_id: tool_call[id], content: str(result), }) # 再用更新后的messages发起第二次模型请求 return chat_with_tools(messages)这段逻辑是所有Agent框架的核心模型请求、工具路由、结果回填。只要把这套循环写稳后续加多少工具都只是增加工具函数和schema的问题。3.5 函数调用的常见坑我刚开始做Function Calling时踩过几个很典型的坑这里直接列出来。第一个坑是工具描述写得太模糊。模型是拿description来对照用户意图的如果你写“获取天气信息”它遇到“明天出门带伞吗”这种问题时可能不知道要调用你。写得具体一点比如“根据城市和日期返回天气信息用于回答出行相关问题”命中率立刻不一样。第二个坑是参数校验缺失。模型有时会返回超出枚举范围的参数执行工具前最好用JSON Schema校验一遍而不是直接信任。第三个坑是并行工具调用。部分模型服务商支持一次请求返回多个tool_calls如果你的工具存在先后依赖关系比如先查库存再下单要注意调用顺序不能简单并行执行。4. 第三种方式把单次请求交给Agent框架封装4.1 框架帮忙做了什么当你的工具越来越多消息拼接、工具路由、上下文维护、错误重试这些逻辑就会占据大量代码。于是出现了各种Agent开发框架帮开发者把“单次模型请求”以及其背后的多步循环整个封装起来。你用框架时往往只需要调用一个类似agent.chat()的方法传入用户消息框架内部会自动完成工具注册、请求构造、tool_calls解析、工具执行、结果回填甚至还能自动管理对话历史。框架的价值主要体现在三块第一工具注册改成装饰器模式代码更清爽第二消息数组自动维护不用每次手动append第三错误重试、超时处理内置生产环境更容易。对不熟悉模型协议的开发者来说框架能让你绕过细节直接做出能跑通的Agent。4.2 一个最小框架调用的写法不同Agent框架的接口略有差别但核心逻辑基本一致。以下是一个典型的最小示例我把具体框架名隐去只保留概念结构from some_agent_framework import Agent, tool tool def get_weather(city: str, unit: str celsius) - str: 查询指定城市天气 return f{city}当前温度是26度 agent Agent( system_prompt你是一个实用Agent需要调用工具回答天气问题。, tools[get_weather], ) reply agent.chat(北京今天热吗) print(reply)框架会根据get_weather的函数签名自动生成工具schema省去手写JSON的麻烦。你写函数时的类型注解、docstring都会变成描述信息。这一点对原型开发特别友好。4.3 框架封装的代价与适用边界但框架不是银弹。它封装得越彻底你离底层协议就越远出问题时越难排查。我见过一个项目模型返回工具调用后框架内部由于消息格式不兼容抛出异常研发查了半天最后只能读框架源码硬是花了一整个下午才定位到是历史消息里多了一个系统注入内容。这就是黑盒封装的代价。框架还往往自带一套版本依赖体系升级主版本时内部处理逻辑可能变化你的Agent行为也会跟着变。除此之外特殊场景比如你想在单次请求里塞多个系统消息、想自定义tool_calls解析策略框架不一定能直接实现你得绕开框架写patch反而比裸调API更复杂。4.4 什么时候不该用框架如果你所在团队对模型接口的调用量很大很多场景需要精细控制请求参数和返回格式我建议至少先裸调API把核心路径写完。只要跑通一次函数调用你就能深刻理解框架背后做了什么。反之如果项目周期很紧、功能偏标准化、团队没有专门研究模型协议的人趁早用框架能省很多工时。我的习惯是“先裸调再封装”。具体是新需求先用方式一和方式二做技术验证确认模型行为符合预期后再把稳定路径抽象成框架可管理的工具或者直接沉淀成自己的轻量封装。这样既能享受框架的高效率又不至于被框架彻底裹挟。5. 完整实操用函数调用方式实现一次带工具的模型请求5.1 场景定义一个带日期查询的Agent为了让你更直观地看到单次模型请求在真实Agent里的位置我用一个完整示例把它们串起来。假设你要做一个会议安排助手用户问“下周三下午有没有空会议室”Agent需要先调用一个check_room_availability工具再根据工具返回结果回答用户。这次请求的关键点在于模型必须先识别日期“下周三”再返回工具调用指令。5.2 第一步写清工具schema我倾向于把工具schema单独放在一个字典里方便复用和测试。以下是一个带日期的会议查询工具定义check_room_schema { type: function, function: { name: check_room_availability, description: 查询指定日期和时间段是否有空会议室日期格式为YYYY-MM-DD, parameters: { type: object, properties: { date: {type: string, description: 日期格式YYYY-MM-DD}, start_time: {type: string, description: 开始时间格式HH:MM}, end_time: {type: string, description: 结束时间格式HH:MM}, }, required: [date, start_time, end_time] } } }注意我特别在description里强调了日期格式。为什么因为模型很容易把“下周三”直接填成自然语言而不填成YYYY-MM-DD。你在description里显式给出格式约束能显著提高参数正确率。5.3 第二步发送带tools的单次请求发送请求的代码和裸调API很相似只是在请求体里多了一个tools字段。为了避免上下文过长这条请求的消息数组里只带系统提示和用户问题也就是一次干净的“单次模型请求”。def send_agent_request(user_text): messages [ {role: system, content: 你负责帮用户查询会议室。遇到需要查询的情况必须调用工具。}, {role: user, content: user_text}, ] resp requests.post( https://api.example.com/v1/chat/completions, headers{Authorization: fBearer {os.environ[MODEL_API_KEY]}}, json{ model: your-model-name, messages: messages, tools: [check_room_schema], temperature: 0.1, }, timeout60, ) resp.raise_for_status() return resp.json()将temperature设置成0.1是故意的。工具调用属于确定性任务过高的随机性会导致模型偶尔不调用工具或者产生奇怪的参数。5.4 第三步执行工具并组织返回拿到响应后先判断tool_calls是否存在。下面的示例把整个过程串起来包括工具执行和结果回填def run_agent_once(user_text): data send_agent_request(user_text) message data[choices][0][message] if not message.get(tool_calls): return message.get(content) # 模型直接回答没有需要调用工具 for call in message[tool_calls]: if call[function][name] check_room_availability: args json.loads(call[function][arguments]) available check_room_availability( dateargs[date], start_timeargs[start_time], end_timeargs[end_time] ) # 把结果回填到消息数组 message_for_followup [ {role: system, content: 你负责帮用户查询会议室。}, {role: user, content: user_text}, message, # 模型第一次返回的消息保留tool_calls { role: tool, tool_call_id: call[id], content: f会议室是否可用: {available} } ] final_resp requests.post( https://api.example.com/v1/chat/completions, headers{Authorization: fBearer {os.environ[MODEL_API_KEY]}}, json{model: your-model-name, messages: message_for_followup}, timeout60, ) return final_resp.json()[choices][0][message][content]注意回填消息时有两个关键细节。第一message_for_followup里必须原样带上模型第一次返回的assistant消息因为它里面有tool_calls协议要求后续请求必须把这个完整消息放回去。第二tool_call_id必须和第一次响应里的id对应这是工具结果和工具调用指令之间的关联锚点。5.5 现场日志长什么样我自己调试时会打印三个东西请求的tools数量、模型的tool_calls原始返回、工具执行后的回填结果。典型的日志应该长这样[Request] messages_len2 tools_len1 temperature0.1 [ToolCall] namecheck_room_availability args{date:2025-11-12,start_time:14:00,end_time:15:00} [ToolResult] availableTrue [Final] 下周三下午2点到3点有空会议室。如果你的日志里ToolCall一直不出现问题多半出在prompt或者工具描述上。如果ToolResult内容和你预期不符问题多半出在函数入参解析上跟模型反倒没关系。5.6 这一步做完Agent循环的轮子就齐了上面只演示了一轮工具调用但你已经把一个Agent循环里所有核心组件都碰过了模型请求、工具路由、参数解析、结果回填。接下来只要在外面加一个while循环就可以实现“模型决定调工具 - 执行 - 结果回填 - 模型再决定”的完整Agent闭环。这也是我一直在团队里强调的观点先搞懂单次模型请求再谈Agent循环。循环只是把单次请求反复执行而已每一步的稳定性都取决于你对单次请求的理解。6. 踩坑实录单次模型请求的高频问题排查6.1 请求超时与限流Agent需要工具调用时一次用户问题往往会触发两次甚至更多次模型请求整体延迟跟单纯聊天完全不同。要提前把单次请求的超时时间设长一点我常用60秒。如果模型服务商有并发限制建议在工具调用逻辑里做一个小型重试器捕获连接超时和限流错误采用退避重试策略。import time def request_with_retry(make_request, max_retries3): for i in range(max_retries): try: return make_request() except ConnectionError: time.sleep(2 ** i) except RateLimitError: time.sleep(2 ** i 1) raise RuntimeError(模型请求连续失败)重试不是万能的。如果持续限流优先检查是不是有多个Agent实例在共享同一个API密钥以及是否触发了每分钟请求数上限。把请求数降下来比无限重试更有意义。6.2 模型不返回tool_calls这是我在函数调用里遇到最多的问题。明明带了tools参数模型就是不给tool_calls直接输出一段文本。排查顺序一般是这样先检查tools是否真的传进了请求体再看工具描述是否足够清晰模型是不是把用户意图理解成了可以靠常识回答最后看temperature太高时模型很容易“偷懒”建议降到0.1左右。还有一种情况是模型认为不需要调用工具就能作答。比如工具是查库存用户却问“你们有什么产品”这属于意图不匹配。此时要么在system里强行要求“遇到任何产品问题都必须调用工具”要么接受模型跳过工具直接回答在业务上做兜底。6.3 arguments解析失败函数调用返回的arguments本质上是模型生成的JSON字符串哪怕模型经过专项训练也有可能生成非法JSON比如缺引号、多逗号。最稳妥的方法是先用json.loads解析失败时不要立刻报错而是把原始字符串拼进新的模型请求让模型自己修正。import json def safe_parse_arguments(arguments): try: return json.loads(arguments) except json.JSONDecodeError: # 不回退直接把原始字符串追加到消息里让模型重新生成 return {_raw: arguments, parse_error: True}如果你做了参数校验会在执行工具前挡住大多数错误。我在生产环境里还会对必填字段做显式检查缺失就返回一个友好错误给模型比如“参数缺少date请重新生成工具调用”让模型在下一轮自行纠正。6.4 上下文太长被截断单次请求看起来只传了一两个消息但工具调用后的回填会不断膨胀。一旦Agent循环轮数增多消息数组很容易超过模型上下文窗口。解决办法有三个对工具返回结果做截断只保留关键信息给历史消息做滑动窗口只保留最近的若干轮调整工具的SDK输出尽量返回精简的机器可读结果而不是大段人类友好的描述。我见过一个明显的例子工具返回了一大段包含HTML标签的页面摘要结果模型接着就把HTML原文背了出来浪费大量token。后来我把工具返回值改成“提取后的前200字”问题立刻消失。6.5 不同模型服务商的兼容性不同模型服务商对函数调用的协议实现高度相似但不完全一致。有的要求tool_choice字段才能强制模型必须调用工具有的对arguments字段的缩进和类型处理不同还有的在多个tool_calls时的排序规则不一致。在切换服务商时不要想当然认为同一个请求体一定能通。有三种实操方法能降低兼容风险第一把模型接口访问层单独隔离封装成自己的函数方便切换第二在测试环境里建立“协议探针”用一个固定工具做一个冒烟测试确认服务商返回的tool_calls格式和你解析逻辑兼容第三统一在请求层把工具schema做一层转换让内部只接触你自己的标准格式。最后聊一点个人体会三种方式不是三选一的关系。我实际项目里的做法是用裸调API跑通第一版确认模型能力和业务预期匹配再花半天时间把核心工具切换成Function Calling保证稳定等工具数量翻了一倍以后才考虑引入框架做更上层的编排。每一次跳跃都基于对前一层原理的理解而不是简单为了“省事”而跳级。最后再分享一个小技巧无论用哪种方式把模型请求的原始响应在日志里完整保留至少一周。很多Agent的问题看起来是代码bug实际上都是模型返回格式漂移引起的。有了原始日志你就能快速分辨到底是模型的问题、工具的问题还是自己解析的问题。这个习惯帮我省了不知道多少排查时间。