1. 从补全对话到构建智能体:Azure OpenAI 的能力跃迁
很多开发者对 Azure OpenAI 的印象还停留在"发一段提示词、收一段回复"的对话补全阶段。这个认知在 2023 年之前基本够用,但放到现在已经明显落后了。真正让生成式 AI 从"玩具"变成"生产力工具"的,是Assistants API、代码解释器(Code Interpreter)和函数调用(Function Calling)这三件套的组合。它们解决的是同一个核心问题:如何让模型不再只是"说",而是能"做"。
我在实际项目里踩过最深的坑,就是一开始把所有需求都往 Chat Completions 上堆。用户问"帮我算一下这个季度的增长率",我就把数据塞进提示词里让模型算。结果呢?模型算加减乘除还行,一旦涉及多步计算、日期处理、文件解析,错误率立刻飙升。原因很简单——大语言模型本质是"文字接龙",它不是计算器,更不是数据库。你让它做它不擅长的事,它就会一本正经地胡说八道。
Assistants API 的出现改变了这个局面。它把"对话"升级成了"会话(Thread)",把"一次性问答"升级成了"有状态的持续交互"。更关键的是,它允许你给助手挂载工具:代码解释器负责跑代码、算数据、画图表、读文件;函数调用负责连接你自己的业务系统,查订单、调库存、发通知。模型从"只会聊天"变成了"能调工具的智能体(Agent)"。
这一篇我打算把这三个能力拆开讲透,重点不是 API 文档里抄一遍参数,而是讲清楚什么时候该用哪个、怎么组合、以及那些文档里不会写的坑。如果你已经能跑通基础的对话补全,想往智能体方向走一步,这篇内容应该能帮你少走不少弯路。
2. Assistants API 的会话模型:为什么它和 Chat Completions 是两套思路
2.1 Thread、Message、Run 三个概念到底在解决什么问题
刚接触 Assistants API 的人,最容易懵的就是这三个词:Thread、Message、Run。我用一个生活化的类比来解释。把 Assistants API 想象成一家餐厅:
- Assistant(助手)是这家餐厅的"主厨",你提前告诉他擅长什么菜系(模型)、能用哪些厨具(工具)、有什么规矩(指令)。
- Thread(会话线程)是一张"餐桌",顾客坐下来之后,所有的对话都发生在这张桌子上,服务员会记住这张桌子上聊过什么。
- Message(消息)是顾客和服务员之间的"每一句话",你问一句、助手答一句,都往这张桌子上放。
- Run(运行)是"主厨开始做菜"这个动作。你把桌子上的对话交给主厨,他看完上下文,决定用什么工具、怎么回复,然后端出结果。
这个模型和 Chat Completions 最大的区别在于状态管理。Chat Completions 是无状态的,每次请求你都得把完整的历史消息数组重新发一遍。对话轮次一多,请求体越来越大,token 成本直线上升,而且你得自己在客户端维护这个数组。Assistants API 把状态托管在服务端,你只需要往 Thread 里追加新消息,服务端自动维护上下文。这在多轮复杂对话场景下,省掉的不只是代码量,还有大量因为上下文拼接出错导致的诡异 bug。
我实测过一个场景:一个需要 20 轮以上追问的客服助手。用 Chat Completions 时,客户端要维护一个不断增长的 messages 数组,到第 15 轮左右请求体已经很大了,而且一旦某轮拼接逻辑有 bug,整个对话就崩了。换成 Assistants API 之后,客户端代码从"维护数组"变成了"往 Thread 里 add message",逻辑清晰了不止一个量级。
2.2 一次完整的 Run 生命周期里藏着哪些状态
Run 不是"发出去就完事"的同步调用,它是一个有生命周期的异步过程。理解这个生命周期,是排查问题的关键。一个 Run 会经历这些状态:
| 状态 | 含义 | 你该做什么 |
|---|---|---|
| queued | 排队中,还没开始处理 | 继续轮询 |
| in_progress | 正在处理 | 继续轮询 |
| requires_action | 需要你提供函数调用结果 | 执行函数并提交结果 |
| completed | 完成 | 读取助手的回复 |
| failed | 失败 | 检查错误信息 |
| cancelled | 被取消 | 按需重试 |
| expired | 超时过期 | 重新发起 Run |
这里最容易踩的坑是requires_action。当助手决定调用一个函数时,Run 会停在这个状态等你。如果你只是傻傻地轮询等 completed,它会一直卡住直到超时。正确的做法是:轮询到 requires_action 时,读取required_action.submit_tool_outputs.tool_calls,拿到函数名和参数,执行你的业务逻辑,然后把结果通过 submit_tool_outputs 提交回去,Run 才会继续。
提示:轮询间隔建议从 500ms 起步,不要用 50ms 这种高频轮询,既浪费配额又容易被限流。我一般用指数退避,从 500ms 开始,每次乘 1.5,上限 3 秒。
2.3 什么时候该用 Assistants,什么时候 Chat Completions 更合适
不是所有场景都值得上 Assistants API。我总结了一个简单的判断标准:
- 用 Chat Completions:单轮问答、简单的多轮对话、对延迟极度敏感(Assistants 因为要轮询,首字延迟通常更高)、不需要工具调用、不需要文件处理。
- 用 Assistants API:需要长期维护对话状态、需要代码解释器处理数据或文件、需要函数调用连接业务系统、需要多轮工具协作的复杂任务。
有个反直觉的点:Assistants API 并不总是"更高级"的选择。如果你的场景就是"用户问一句、模型答一句",硬上 Assistants 反而增加了轮询复杂度和延迟。我见过有团队为了"用上新东西"把简单问答也改成 Assistants,结果延迟翻倍、成本上升,得不偿失。工具是拿来解决问题的,不是拿来炫技的。
3. 代码解释器:让模型真正会算数、会读文件、会画图
3.1 代码解释器到底在沙箱里做了什么
代码解释器(Code Interpreter)的本质,是给助手挂载了一个隔离的 Python 沙箱环境。当模型判断需要计算、数据处理或文件操作时,它会生成一段 Python 代码,在沙箱里执行,然后把执行结果(标准输出、生成的图片、生成的文件)返回给模型,模型再基于结果组织自然语言回复。
这个机制解决了一个根本矛盾:大语言模型不擅长精确计算,但 Python 擅长。与其让模型硬算,不如让它"写代码让 Python 算"。我实测过一个数据清洗任务:给一个 5000 行的 CSV,要求按某列分组求均值并画柱状图。纯提示词方案下,模型要么算错、要么编数据;挂上代码解释器后,它自己写了 pandas 代码,跑出结果,还顺手用 matplotlib 画了图,整个过程一次通过。
代码解释器能做的事,大致分四类:
- 数学计算:多步运算、统计、矩阵运算,精度远超模型直接算。
- 文件处理:读取上传的 CSV、Excel、JSON、PDF、图片等,做解析和转换。
- 数据可视化:生成折线图、柱状图、散点图等,直接返回图片文件。
- 代码执行:跑用户提供的或模型生成的代码片段,验证逻辑。
3.2 文件上传与引用的完整链路
代码解释器要处理文件,文件得先"进得去"。这里的链路是:上传文件 → 拿到 file_id → 在 Message 里引用 file_id → 助手在沙箱里访问。
上传文件用 Files API,关键是purpose参数。给代码解释器用的文件,purpose 要设成assistants。上传成功后拿到一个 file_id,然后在创建 Message 时,把 file_id 放进 attachments 里:
from openai import AzureOpenAI client = AzureOpenAI( azure_endpoint="https://your-resource.openai.azure.com/", api_key="your-api-key", api_version="2024-05-01-preview" ) # 上传文件 file = client.files.create( file=open("sales_data.csv", "rb"), purpose="assistants" ) # 创建带附件的消息 message = client.beta.threads.messages.create( thread_id=thread.id, role="user", content="帮我分析这份销售数据,按地区分组求总销售额,并画个柱状图", attachments=[{ "file_id": file.id, "tools": [{"type": "code_interpreter"}] }] )这里有个容易忽略的细节:attachments 里的 tools 字段必须显式声明 code_interpreter,否则助手可能不会去读这个文件。我第一次用的时候没写这个字段,助手一直说"我看不到文件内容",排查了半天才发现是这里的问题。
3.3 沙箱环境的边界与那些"想当然"的坑
代码解释器虽然强大,但它的沙箱有明确边界,不了解这些边界就会踩坑:
- 无网络访问:沙箱不能联网,所以
pip install装不了新包,requests请求不了外部 API。它只能用预装的库(pandas、numpy、matplotlib 等常见库基本都有)。 - 会话级隔离:同一个 Thread 里的多次 Run 共享沙箱文件系统,但不同 Thread 之间完全隔离。你在 Thread A 里生成的文件,Thread B 访问不到。
- 文件有生命周期:沙箱里的文件不是永久的,Run 结束后一段时间会被清理。如果需要保留生成的文件,要在 Run 完成后主动下载。
- 执行时间有限制:单次代码执行有超时限制,跑太久的任务会被中断。
我踩过最典型的一个坑:让助手处理一个需要调用外部 API 的任务。模型很聪明地写了requests.get(...),结果沙箱直接报网络错误。后来我改成用函数调用(Function Calling)把外部请求这一步交给自己的后端做,才跑通。这个经验说明:代码解释器负责"算",函数调用负责"连",两者分工要清楚。
注意:不要指望代码解释器能装任意第三方库。如果你的任务依赖某个冷门库,要么提前确认沙箱里有,要么把这段逻辑挪到自己的后端用函数调用实现。
4. 函数调用:把模型接到你自己的业务系统上
4.1 函数调用的本质是"模型决定调什么,你决定怎么执行"
函数调用(Function Calling)这个名字容易让人误解,以为模型会直接执行函数。实际上,模型做的只是决定"该调用哪个函数、传什么参数",真正的执行永远在你的代码里。这个设计是刻意的,也是安全的——模型永远不会直接碰你的数据库、你的内部 API。
整个流程是这样的:
- 你定义一组函数(名称、描述、参数 schema),告诉助手"你有这些能力"。
- 用户提问后,模型判断需要调用某个函数,返回函数名和参数。
- 你的代码执行这个函数,拿到结果。
- 你把结果提交回去,模型基于结果组织最终回复。
这个"模型决策、你来执行"的分工,是函数调用最核心的设计哲学。它既给了模型灵活性,又把执行权牢牢握在你手里。
4.2 函数 schema 怎么写才能让模型"调得准"
函数调用调得准不准,八成取决于 schema 写得好不好。我见过太多人随便写个描述就上线,然后抱怨"模型老是调错函数"。问题往往出在描述上。
一个好的函数定义,要包含这几样:
tools = [ { "type": "function", "function": { "name": "get_order_status", "description": "根据订单号查询订单的当前状态。当用户询问订单进度、物流状态、是否发货时使用此函数。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,格式为纯数字,例如 20240513001" } }, "required": ["order_id"] } } } ]几个关键点:
- description 要写"什么时候用",不只是"这个函数干什么"。模型是靠描述来判断调用时机的,你写清楚触发场景,它才调得准。
- 参数描述要具体,包括格式、示例。参数类型和格式写清楚,能大幅减少模型传错格式的情况。
- 函数名要语义化,
get_order_status比func1强太多。 - 参数尽量用简单类型,嵌套对象和复杂数组容易让模型出错。
我做过一个对比测试:同一组功能,描述写得详细和写得敷衍,模型调用准确率差了将近 30 个百分点。这个投入产出比非常高,值得花时间打磨。
4.3 处理 requires_action:函数调用的完整闭环
函数调用的闭环,核心就是处理requires_action状态。下面是一个完整的处理逻辑:
import time def run_with_functions(thread_id, assistant_id): run = client.beta.threads.runs.create( thread_id=thread_id, assistant_id=assistant_id ) while run.status in ["queued", "in_progress", "requires_action"]: if run.status == "requires_action": tool_calls = run.required_action.submit_tool_outputs.tool_calls tool_outputs = [] for call in tool_calls: func_name = call.function.name args = json.loads(call.function.arguments) # 根据函数名分发到实际业务逻辑 if func_name == "get_order_status": result = query_order_from_db(args["order_id"]) elif func_name == "send_notification": result = send_notify(args["user_id"], args["message"]) else: result = {"error": "unknown function"} tool_outputs.append({ "tool_call_id": call.id, "output": json.dumps(result, ensure_ascii=False) }) run = client.beta.threads.runs.submit_tool_outputs( thread_id=thread_id, run_id=run.id, tool_outputs=tool_outputs ) else: time.sleep(1) run = client.beta.threads.runs.retrieve( thread_id=thread_id, run_id=run.id ) return run这里有几个实战要点:
- tool_call_id 必须原样回传,它是模型匹配"哪个调用对应哪个结果"的凭据,传错了结果就对不上。
- output 建议用 JSON 字符串,结构化的返回比纯文本更容易让模型理解。
- 一次 Run 可能触发多个函数调用,要遍历 tool_calls 全部处理,不能只处理第一个。
- 函数执行要加超时和异常处理,你的业务系统可能挂,不能让一个函数卡死整个 Run。
4.4 函数调用和代码解释器怎么配合
这两个工具不是二选一,而是可以同时挂载、协同工作的。典型的分工是:代码解释器负责计算和数据处理,函数调用负责连接外部系统。
举个我实际做过的例子:一个财务分析助手。用户上传一份流水 CSV,问"这个月哪些供应商的付款超过了预算"。整个流程是:
- 代码解释器读取 CSV,做数据聚合,算出每个供应商的付款总额。
- 函数调用去查预算系统,拿到每个供应商的预算上限。
- 模型对比两者,找出超预算的供应商,生成报告。
这个组合的威力在于,它把"模型不擅长的精确计算"和"模型够不到的外部数据"都补齐了,模型只负责它最擅长的部分——理解意图、编排流程、组织语言。这才是智能体该有的样子。
5. 把三个能力串起来:一个可复现的智能体搭建实录
5.1 场景定义与助手配置
光讲概念不够,我用一个完整场景把三个能力串起来。场景是:一个能查订单、能算数据、能画图的电商客服助手。用户可以说"帮我查一下订单 20240513001 的状态",也可以说"把最近三个月的销售数据画个趋势图"。
先创建助手,把代码解释器和函数调用都挂上:
assistant = client.beta.assistants.create( name="电商智能客服", instructions="""你是一个电商客服助手。你可以: 1. 查询订单状态(用 get_order_status 函数) 2. 分析销售数据、生成图表(用代码解释器) 回答要简洁专业,涉及数据时给出具体数字。""", model="gpt-4o", # 部署名,按你的实际部署填 tools=[ {"type": "code_interpreter"}, { "type": "function", "function": { "name": "get_order_status", "description": "查询订单状态。用户询问订单进度、物流、发货情况时使用。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,纯数字" } }, "required": ["order_id"] } } } ] )instructions 里我特意写清楚了"什么时候用哪个工具",这能显著提升模型选择工具的准确率。别小看这几句话,它相当于给模型的工作手册。
5.2 多轮对话中的工具切换实测
创建 Thread 之后,我做了几轮测试,观察模型怎么在工具之间切换。
第一轮,用户问"订单 20240513001 到哪了"。模型判断需要 get_order_status,Run 进入 requires_action,我的后端查到"已发货,预计明天送达",提交回去,模型组织成自然语言回复。整个过程干净利落。
第二轮,用户接着问"那帮我看看最近三个月的销售趋势"。注意,这里用户没说"上传文件",但 Thread 里之前如果有上传过数据文件,代码解释器能直接访问。模型判断需要代码解释器,生成 pandas 代码读取数据、聚合、画图,返回一张趋势图。这里有个细节:同一个 Thread 里,代码解释器生成的文件在后续 Run 里还能访问,所以多轮数据分析可以复用之前的结果。
第三轮,用户问"超预算的供应商有哪些"。这一轮同时触发了两个工具:代码解释器先算各供应商付款额,函数调用再查预算,模型最后对比。Run 会先进入一次 requires_action(函数调用),提交结果后继续,可能再触发代码解释器,最终完成。
实测下来,模型在工具切换上的表现比我预期好,但有两个地方需要人工兜底:一是工具选择偶尔会犹豫,尤其是问题模糊时;二是多工具协作时的中间结果传递,偶尔会出现模型"忘了"前一步的结果。前者靠优化 description 解决,后者靠把关键中间结果显式写进 instructions 或让模型在回复里复述。
5.3 成本、延迟与并发:上线前必须算的三笔账
跑通 demo 只是第一步,上线前得算清楚三笔账。
成本账:Assistants API 的计费比 Chat Completions 复杂。除了 token 费用,代码解释器按会话时长计费,文件存储也单独计费。一个挂着代码解释器的助手,如果用户开着会话不关,沙箱会一直占着,费用悄悄涨。我的做法是设置会话超时,用户不活跃一段时间就主动清理 Thread。
延迟账:Assistants 因为要轮询,首字延迟天然比 Chat Completions 高。实测下来,简单问答场景 Assistants 比 Chat Completions 慢 1-2 秒,涉及工具调用的复杂任务慢得更多。如果产品对延迟敏感,得权衡是否值得。
并发账:每个 Thread 同一时间只能有一个活跃 Run。如果用户快速连发多条消息,第二条会排队。我的处理方式是前端做输入节流,用户发消息后禁用输入框直到收到回复,避免并发冲突。
| 维度 | Chat Completions | Assistants API |
|---|---|---|
| 状态管理 | 客户端维护 | 服务端托管 |
| 首字延迟 | 低 | 较高(轮询开销) |
| 工具能力 | 无 | 代码解释器 + 函数调用 |
| 计费复杂度 | 简单(按 token) | 复杂(token + 会话 + 存储) |
| 适用场景 | 简单问答 | 复杂智能体 |
6. 那些文档里不会写的实战心得
6.1 指令(instructions)的写法决定了助手的下限
很多人把 instructions 当成"随便写写的系统提示",这是大错特错。在 Assistants API 里,instructions 是助手的"人格 + 工作手册",它决定了助手的行为下限。我总结了几条写法心得:
- 明确工具使用时机:不要只说"你能用代码解释器",要说"当用户要求计算、分析数据、生成图表时,使用代码解释器"。
- 规定输出格式:如果你希望回复结构化,就在 instructions 里写清楚格式要求。
- 设定边界:明确告诉助手"不知道就说不知道,不要编造",能有效降低幻觉。
- 控制语气:客服场景要亲切,技术场景要严谨,这些都要在 instructions 里定调。
我做过 A/B 测试,同一套工具,instructions 写得详细和写得简单,用户满意度差了将近 40%。这个投入产出比,比换模型还高。
6.2 轮询不是唯一选择:流式与事件驱动的取舍
前面讲的都是轮询方案,但轮询有个天然缺陷:延迟高、浪费请求。Assistants API 其实支持流式(Streaming),可以边生成边返回,体验好很多。流式模式下,你通过 SSE 接收事件,包括thread.message.delta(文本增量)、thread.run.requires_action(需要函数调用)等。
流式的代价是实现复杂度上升。你得处理各种事件类型、维护连接状态、处理断线重连。我的建议是:面向 C 端用户的产品用流式,内部工具或后台任务用轮询。C 端用户对延迟敏感,流式的"打字机效果"体验好;内部工具更看重稳定和简单,轮询够用。
6.3 从 demo 到生产:我踩过的三个真实坑
最后分享三个我踩过的真实坑,都是文档里不会写的。
坑一:文件没清理导致存储费用失控。代码解释器生成的文件、用户上传的文件,都会占用存储。我一开始没做清理,跑了一个月发现存储费用涨了不少。后来加了定时任务,定期清理超过 7 天的文件。
坑二:函数调用超时拖垮整个 Run。有一次我的订单查询接口响应变慢,函数调用卡了 30 秒,整个 Run 一直卡在 requires_action,用户那边转圈转到放弃。后来我给所有函数调用加了 5 秒超时,超时就返回"查询超时,请稍后重试",Run 能正常结束。
坑三:instructions 里的日期写死了。我在 instructions 里写了个示例日期,结果模型有时候会把这个示例日期当成"当前日期"用。后来改成动态注入当前日期,问题解决。这个坑很隐蔽,因为模型大部分时候是对的,只在特定提问下才暴露。
提示:生产环境一定要给 Run 加超时和重试逻辑。网络抖动、服务端偶发故障都会让 Run 失败,没有重试机制的话用户体验会很差。
6.4 关于"无限制生成"这类需求的理性看待
最近有些热词提到"无限制无审核生成式 AI",我想说几句实在话。任何负责任的云服务,都会内置内容安全机制,这是平台合规的底线,也是保护用户和开发者自己的必要措施。Azure OpenAI 的内容过滤是默认开启的,而且可以按需配置过滤级别。
作为开发者,正确的思路不是去找"绕过审核"的歪门邪道,而是在合规框架内把产品做好。内容过滤不是障碍,它帮你规避了大量法律和声誉风险。如果你的应用场景确实需要更宽松的过滤策略,正规途径是通过 Azure 的申请流程调整过滤配置,而不是用非正规手段。这一点,做过 To B 项目的同行应该都有体会——合规成本是必须付的,省不得。
7. 下一步可以往哪里走
把 Assistants API、代码解释器、函数调用这三样吃透,你已经能搭出相当能打的智能体了。再往上走,有几个方向值得探索。
一是多助手协作。复杂任务可以拆成多个助手,各司其职,用一个"调度助手"来编排。比如一个负责查数据、一个负责分析、一个负责写报告,通过函数调用互相触发。
二是接入向量检索。Azure OpenAI 有配套的检索能力,可以把企业知识库挂上去,让助手基于私有文档回答。这比把文档塞进提示词要高效得多,也更适合知识量大的场景。
三是可观测性建设。生产环境的智能体,光跑通不够,还得能监控。记录每次 Run 的状态、工具调用、耗时、token 消耗,出问题时能快速定位。这块我建议尽早做,别等出事了才补。
我个人在实际项目里的体会是:Assistants API 这套东西,入门不难,难的是把边界摸清楚、把异常处理做扎实。demo 跑通可能只要半天,但要做到生产可用,得在错误处理、超时重试、成本控制、内容合规这些"不性感"的地方下功夫。这些功夫看不见,但决定了你的智能体是能上线还是只能演示。