GPTs 与 Assistant API:托管式 Agent 的现状与迁移
把助理连同他的档案柜一起外包给中介公司——省心、快、少写代码,代价是中介关门那天,你的上下文跟着一起消失。
30″30 秒看懂托管式 Agent
你要给自家水果店配一位财务助理。第一种办法是自己招人、自己给他一间办公室、自己买一个档案柜;第二种办法是找中介公司外包——人是中介的人,档案柜也摆在中介的楼里,你手上只留一张纸条,上面写着两个编号:助理编号、档案袋编号。
托管式 Agent 走的就是第二条路。你在平台上签一份岗位说明书(用哪个模型、什么人设、能用哪些工具),平台给你一个 assistant_id;每来一位客户,你让中介开一个档案袋,拿到 thread_id;客户说的每句话,你写成信件塞进袋子;要办事的时候,你喊一声「照着档案办」,助理就翻档案、查资料、算账、把回信也放进同一个袋子。整个过程你没看见助理,也没摸到档案——你只在收发编号。
| 比喻里的角色 | 对应的技术概念 | 它到底是什么 |
|---|---|---|
| 签了岗位说明书的助理 | Assistant | 模型 + instructions + tools 打包成的服务端对象,建一次可被无数会话复用 |
| 中介替你保管的档案袋 | Thread | 一位客户的会话容器,本身不含任何配置,只是一个有序的信件盒 |
| 袋子里的一张张信件 | Message | user 说的、assistant 写的,按顺序存着;写进去不会触发模型 |
| 喊一声「照着档案办」 | Run | 在某个 Thread 上用某个 Assistant 跑一次;异步,立刻返回一个 id,结果得回头去取 |
| 助理办事的流水记录 | Run Step | 这次运行内部调了什么工具、写了什么消息,用来事后排查 |
| 你手上的那张纸条 | 两个字符串 id | 你在本地唯一拥有的东西;档案的正本从来不在你这儿 |
零代码那一半(GPTs 这一类产品)连纸条都不用你拿:岗位说明书在网页表单里填,档案袋由平台的聊天界面自动开,分发靠一条平台内的分享链接。它和写代码那一半是同一套东西的两张脸——底下都是「配置 + 会话 + 运行」三件事托管在别人家。
01概念:托管式 Agent 的两张脸
零代码的 GPTs、写代码的托管式接口、二者的联系与区别,以及 OpenAI 侧今天的真实状况
1.1 GPTs:零代码的那一半
2023 年 11 月,OpenAI 为 ChatGPT 推出了 GPTs:允许用户无需写代码,就能根据特定需求创建「属于自己的 ChatGPT 版本」,也就是基于 ChatGPT 做出一个定制化的个人 AI 助手。到 2024 年 1 月,个性化 GPT 的数量已经以百万计。
它的做法一句话说得清:把原本要写在代码里的三件事,搬进一张网页表单。
在编排页面填一段角色设定:你是谁、擅长什么、遇到什么不回答、输出用什么格式。这段文字就是后面代码里的 instructions。
上传若干文档,平台替你切片、向量化、建索引。提问时自动检索,检索到的片段拼进上下文——也就是代码里的 Retrieval 工具。
勾选内置工具(联网搜索、画图、代码执行),或挂一个外部接口。对应代码里的 tools 数组。
零代码平台的杀手锏不是「省了几十行代码」,而是分发:配好之后生成一条平台内的分享链接,别人点开就能用,不需要你准备服务器、域名、登录体系、前端界面。国产平台在这一点上更进一步,可以把配好的 bot 直接投放到聊天工具、公众号等渠道——这条路后面的模块会专门走一遍。
1.2 托管式接口:写代码的那一半
2023 年 11 月 6 日,OpenAI 在开发者大会上发布了面向开发者的 Assistants API。它是一个开发者工具,允许开发者在自己的应用程序中构建人工智能助手;这些助手根据指令 instructions 运作,可以利用模型 models、工具 tools 和知识库 knowledge 来回应用户的查询。
当时它支持三类工具:
| 工具 | 原形 | 干什么 |
|---|---|---|
| 代码解释器 | Code Interpreter | 在平台的沙箱里真的执行一段 Python:算数、画图、处理上传的表格 |
| 检索 | Retrieval | 把挂载的文件切片建索引,回答时自动检索——知识库这件事由平台包办 |
| 函数调用 | Function calling | 模型写出要调哪个函数、参数是什么,执行仍然是你的代码 |
注意第三项:Function calling 在托管式接口里并没有消失,只是被包进了运行状态机——运行会停在一个「等你交作业」的状态上,你执行完函数再把结果提交回去,运行才继续往下跑。这一点在 2.3 会拆开讲。
1.3 联系与区别:一体两面
| 维度 | GPTs 这类零代码产品 | 托管式接口 |
|---|---|---|
| 底座 | 同一批大语言模型,同一套「指令 + 知识库 + 工具」的定制方式 | |
| 创建方式 | 无代码,在网页表单里填 | 写代码调接口集成进自己的程序 |
| 用户在哪里用 | 直接用平台自带的聊天界面 | 开发者自己构建界面:App、小程序、公众号、网页 |
| 分享分发 | 内置分享功能,一条链接就能发出去 | 没有内置分享,分发是你自己的事 |
| 状态在谁那里 | 都在平台那里。区别只是「你看不看得见那些 id」 | |
| 面向谁 | 产品、运营、想快速验证想法的人 | 要把 AI 能力嵌进现有业务系统的开发者 |
用比喻收一下:GPTs 是中介直接把助理派到自己门店的柜台上坐班,客人走进中介的店里找他;托管式接口是助理仍在中介的楼里,但你在自己店里装了一部电话,客人对着你的柜台说话,你在后台打电话过去。两种做法里,档案柜都不在你的店里。
1.4 OpenAI 侧的现状:这套接口已经下线了
所以今天读到任何一段 client.beta.assistants.create(...) 的 OpenAI 示例代码,结论都是一样的:照着敲会失败,不是网络问题,也不是密钥问题。正确的动作是照 2.6 的映射表改写。
但这不意味着这一讲可以跳过。三个理由:
「配置 / 会话 / 运行」这三件事托管在服务端,是托管式 Agent 的通用抽象。国产平台(MiniMax、阿里百炼等)今天仍在用完全一样的四对象,接口名几乎逐字对应。
Responses 不是推倒重来,而是把四对象拆细、泛化。理解了旧的四对象,新接口的每个概念都能找到出处,迁移就成了查表替换。
一个由头部厂商发布、写进无数教程的接口,从发布到下线不到三年。怎么设计才能不被一次下线打死,比任何一个具体接口都值钱。
后面的顺序就按这三条走:先把四对象讲透(第 02 节),再讲它为什么被换掉、换成了什么(2.5、2.6),然后用真实可跑的代码走三条路——托管式、迁移后、自建(第 03、04 节)。
02原理:四个对象、一台状态机、一次换代
从属关系与时间轴、Run 的状态分支、这套抽象解决了什么问题、为什么又被换掉、新旧怎么对应
2.1 四对象心智模型
托管式接口的全部复杂度,就在这四个(严格说是五个)对象上。先把官方定义逐条读一遍,再用比喻复述:
| 对象 | 定义 |
|---|---|
Assistant助手 | 一个特定目的的人工智能助手,它使用平台的模型并调用工具来执行任务。开发者可以构建 Assistant 来响应用户的特定需求。 |
Thread线程 | 代表助手和用户之间的对话会话。线程存储消息,并自动处理内容截断,以适应模型的上下文限制。 |
Message消息 | 由助手或用户创建的消息。消息可以包括文本、图像和其他文件类型,并以列表形式存储在线程上。 |
Run运行 | 在线程上调用助手的一个实例。助手使用其配置和线程上的消息,通过调用模型和工具来执行任务。作为运行的一部分,助手会向线程追加消息。 |
Run Step步骤 | 助手在运行过程中所采取的详细步骤列表。助手可以在运行过程中调用工具或创建消息。检查运行步骤可以让你内省助手是如何得出最终结果的。 |
读定义容易滑过去,有三处措辞值得停一下:
Thread 的这句定义,是托管式路线最大的省事之处:对话再长也不用你算 token、不用你决定丢哪几轮。自建时这件事得自己补回来(见 4.4)。
回复不是 Run 的返回值,而是被追加进 Thread 的一条新 Message。所以拿结果的动作永远是「回袋子里取最新那封信」。
Run 是一次运行的实例,不是助手本身。同一个 Thread 上可以先后跑很多次 Run,每次都可以指定不同的 Assistant。
比喻复述
Assistant 是那份签好的岗位说明书——写明这位助理用什么脑子、按什么规矩办事、手上有哪些权限工具。它跟具体哪位客户无关,所以建一次就够,可以被无数个档案袋复用。
Thread 是一个客户的档案袋——它是空的、没有任何配置,只是按顺序装信。Message 是袋子里的一封信,写进去这件事本身不惊动任何人,只是存档。Run 才是你冲后台喊的那一嗓子:「照着这个袋子里的档案办一次」。Run Step 是助理的工作流水:几点几分翻了哪份资料、调了什么工具、写了什么字。
2.2 一次问答的六步
实现流程固定为下面这几步,顺序不能乱:
指定自定义的指令和一个模型,在接口中创建一个 Assistant。如果需要,还可以启用代码解释器、信息检索和函数调用等工具。这是一次性动作,之后每次对话都跳过它。
用户开始交谈时,创建一个对话线程 Thread。建议一位客户 / 一个会话一个 Thread,并把返回的 id 存进自己的库。
用户提出问题时,向对话线程中添加消息 Message。此刻模型还没有被调用,你只是往袋子里塞了一封信。
对对话线程执行运行 Run 动作,以激活 Assistant 的响应。这一过程会自动调用相关的工具。请求立刻返回一个 run_id,事情还在平台那边跑。
拿 thread_id + run_id 反复查状态,直到进入终态。要用退避,不要裸 sleep,理由见 2.3。
回到 Thread 上列出消息,最新那条 assistant 消息就是回复。忘了这一步,会出现「状态明明 completed,却什么也没拿到」。
2.3 Run 是一台状态机,而且有一个分支必须你来接
Run 不是「发出去就有结果」。它是有限状态机,典型状态是这么流转的:
requires_action 不是终态。把它当终态处理,是这套接口最常见的错:现象是「状态查到了,消息列表却永远是空的」,程序悄悄地什么都没干。它的含义是:助手决定要调一个你注册的函数,而函数在你的机器上,平台执行不了——铁律在这里依然成立,执行函数的永远是你的代码。托管式接口做的只是把这个交接动作包进了状态机。
轮询也有讲究。写死 time.sleep(2) 有两个毛病:任务两秒内就结束时白等,任务要跑一分钟时三十次请求全打在服务端上。正确写法是指数退避 + 上限 + 总超时:
"""retrykit.py —— 托管式接口必备的两件事:轮询退避与失败重试。
托管式接口把「跑一次」做成了异步任务:提交 Run 之后立刻返回,
状态要自己轮询。写死 time.sleep(2) 的做法有两个毛病:
· 任务很快就结束时,白白等满 2 秒;
· 任务要跑一分钟时,30 次请求全打在服务端上。
指数退避 + 上限 + 总超时,是这类接口的标准写法。
"""
import random
import time
def backoff_delays(base=0.5, factor=1.8, cap=8.0, jitter=0.25):
"""生成一串逐渐变长的等待秒数:0.5 → 0.9 → 1.6 → 2.9 → … → 封顶 8 秒。
jitter 是抖动比例,避免多个进程在同一毫秒齐刷刷地重试(惊群)。
这是一个无限生成器,由调用方决定什么时候停。
"""
delay = base
while True:
shake = delay * jitter * (random.random() * 2 - 1)
yield max(0.05, delay + shake)
delay = min(cap, delay * factor)
def poll_until(fetch_status, terminal, timeout=180.0, on_tick=None, **kw):
"""反复调用 fetch_status(),直到状态进入 terminal 集合或超时。
参数
fetch_status 无参函数,返回当前状态字符串
terminal 终态集合,例如 {"completed", "failed", "cancelled", "expired"}
timeout 总超时秒数;超时抛 TimeoutError,而不是无限循环
on_tick 每轮回调,签名 on_tick(轮次, 状态, 已用秒数)
返回
最终状态字符串
"""
started = time.time()
delays = backoff_delays(**kw)
turn = 0
while True:
turn += 1
status = fetch_status()
used = time.time() - started
if on_tick:
on_tick(turn, status, used)
if status in terminal:
return status
if used >= timeout:
raise TimeoutError(
"轮询超时:%.1f 秒后状态仍为 %r(已轮询 %d 次)。"
% (used, status, turn)
)
time.sleep(next(delays))
def retry(call, times=3, retry_on=(), on_fail=None, **kw):
"""把一次网络调用包成「失败自动重试」的版本。
retry_on 传异常类型元组;网关 502、连接重置这类瞬时故障才该重试。
参数校验错、鉴权错重试多少次都是同样的结果,不要浪费配额。
"""
delays = backoff_delays(**kw)
last = None
for i in range(1, times + 1):
try:
return call()
except retry_on as exc: # noqa: B014 —— 由调用方指定类型
last = exc
if on_fail:
on_fail(i, exc)
if i == times:
break
time.sleep(next(delays))
raise last
if __name__ == "__main__":
# 无网络自检:用一个假状态机验证退避与终态判定是否正确。
seq = ["queued", "queued", "in_progress", "in_progress", "completed"]
box = {"i": 0}
def fake():
s = seq[min(box["i"], len(seq) - 1)]
box["i"] += 1
return s
final = poll_until(
fake,
terminal={"completed", "failed"},
timeout=30,
base=0.05, cap=0.2,
on_tick=lambda n, s, t: print("第 %d 轮 状态=%s 已用 %.2fs" % (n, s, t)),
)
print("终态:", final)
下面这段用一个假后端把四条状态路径全演了一遍,不联网也能跑,拿它对照自己的轮询逻辑最省事:
"""run_state_machine.py —— 把 Run 的状态机跑一遍(不联网,可直接执行)。
托管式接口最容易被忽略的一点:Run 不是「发出去就有结果」,
它是一个有限状态机,而且有一个分支必须你来接手:
queued ──► in_progress ──► completed 正常结束
│
├──► requires_action ──► (你执行本地函数并提交输出)──► in_progress
│
├──► failed / cancelled / expired 异常结束
把 requires_action 当成终态,是新手最常见的错误:现象是
「明明状态查到了,消息列表却永远是空的」。
这份脚本用一个假后端把四条路径都演一遍,方便对照自己的轮询逻辑。
python3 run_state_machine.py
"""
import json
from retrykit import poll_until
TERMINAL = {"completed", "failed", "cancelled", "expired"}
NEEDS_YOU = "requires_action"
class FakeRun:
"""假的平台 Run:按预设脚本吐状态,并在 requires_action 时索要工具输出。"""
def __init__(self, script):
self.script = list(script)
self.i = 0
self.submitted = []
def status(self):
s = self.script[min(self.i, len(self.script) - 1)]
self.i += 1
return s
def required_calls(self):
return [{"id": "call_1", "name": "lookup_fruit",
"arguments": json.dumps({"name": "葡萄"}, ensure_ascii=False)}]
def submit_tool_outputs(self, outputs):
"""提交之后平台会把 Run 推回 in_progress,继续跑。"""
self.submitted.extend(outputs)
self.script = ["in_progress", "completed"]
self.i = 0
def lookup_fruit(name):
table = {"葡萄": {"cost": 2.0, "price": 4.0}}
return table.get(name, {"found": False})
REGISTRY = {"lookup_fruit": lookup_fruit}
def drive(run, label):
"""完整的驱动循环:轮询 → 遇到 requires_action 就执行并提交 → 继续轮询。"""
print("== %s ==" % label)
while True:
status = poll_until(
run.status,
terminal=TERMINAL | {NEEDS_YOU},
timeout=20, base=0.02, cap=0.05,
on_tick=lambda n, s, t: print(" 轮询 %d:%s" % (n, s)))
if status == NEEDS_YOU:
outs = []
for call in run.required_calls():
fn = REGISTRY[call["name"]]
result = fn(**json.loads(call["arguments"]))
print(" 执行 %s -> %s" % (call["name"], result))
outs.append({"tool_call_id": call["id"],
"output": json.dumps(result, ensure_ascii=False)})
run.submit_tool_outputs(outs)
continue # 提交完继续等,别在这里退出
print(" 终态:%s\n" % status)
return status
if __name__ == "__main__":
drive(FakeRun(["queued", "in_progress", "completed"]), "一路顺利")
drive(FakeRun(["queued", "in_progress", "requires_action"]), "需要你执行函数")
drive(FakeRun(["queued", "failed"]), "平台侧失败")
drive(FakeRun(["queued", "in_progress", "expired"]), "超时过期")
retry() 的 retry_on 参数就是为了强迫调用方明确这件事。
2.4 这套抽象到底解决了什么问题
要判断该不该走托管式路线,先得知道它替你干了哪些活。拿它和「裸调 Chat Completions 自己拼 messages」对比:
| 要解决的问题 | 裸调接口时你要做什么 | 托管式接口替你做了什么 |
|---|---|---|
| 多轮上下文 | 自己建表存历史,每次把整个 messages 数组重发一遍 | Thread 存着,Run 时自动带上,只发新消息 |
| 上下文超长 | 自己数 token、自己决定丢哪几轮、自己做摘要 | 线程自动处理内容截断以适应上下文限制 |
| 知识库 | 切片、向量化、建索引、检索、拼提示词,一整条 RAG 链路 | 上传文件 + 启用 Retrieval,两行配置 |
| 代码执行 | 自己搭沙箱,还要担心逃逸与资源限制 | 启用 Code Interpreter,在平台沙箱里跑 |
| 工具调用循环 | 自己写 while:取 tool_calls、执行、回填、再调 | 循环在平台内部,只在需要你执行时停在 requires_action |
| 配置复用 | 提示词散在代码各处,改一次要找好几个文件 | instructions 收在一个服务端对象上,改一处生效 |
一句话:它把「对话状态管理」这件脏活整体外包了。对小团队、对要快速验证的场景,这是实打实的加速。
2.5 那它为什么又被换掉
省事的代价,恰好也是它被换掉的原因。把问题列清楚:
⚠️ 托管式四对象的四个结构性问题
- 台阶太多。问一句话要建助手、建线程、加消息、跑运行、轮询、取消息,六次网络往返起步。对「一问一答、不需要长期会话」的绝大多数场景,这是纯粹的负担。
- 配置不可版本化。Assistant 是个可变的服务端对象,改了 instructions 就地生效,没有版本、不能回滚,也很难做灰度:线上出问题时你甚至说不清当时用的是哪一版提示词。
- Thread 只存 message,过程不可见。工具调用、工具输出这些中间过程不在 Thread 里,要看得另查 Run Step。想完整复现一次会话,得把两套对象拼起来。
- 异步状态机把简单事情复杂化。绝大多数请求几秒就结束,却强制所有人写一套轮询、退避、超时、终态判定的代码;而真正需要长任务的场景,又需要更强的编排能力。
Responses API 的思路正是针对这四条:把「一次运行」直接做成一个请求(送进 input item、拿回 output item,不再有 run 状态机),把配置抽成可版本化的对象,把会话容器从「存 message」升级成「存 item」,并把工具调用循环交还给开发者显式书写。它同时带来了托管式四对象没有的新能力:deep research、MCP、computer use。
previous_response_id 来串上下文——改用 conversation 对象承载。这让「会话」重新变成一个显式、可列举、可导出的东西,而不是靠一串 id 首尾相接。
2.6 新旧对象映射:迁移就是查这张表
四个旧对象,各自被谁接了班:
| 旧对象 | 新对象 | 变化与影响 |
|---|---|---|
Assistants | Prompts | 配置(模型 / 工具 / 指令)变得可版本化、可回滚。但有一条硬限制:Prompts 只能在控制台里创建,不能通过接口创建。所以「程序启动时自动建一个助手」这种写法必须拆掉——改成先在控制台备好、代码里按 id 引用,或者干脆把指令随每次请求传。 |
Threads | Conversations | 从只能存 message 变成存 item:消息、工具调用、工具输出都是 item。读历史的代码要改:先看 item 的 type,再决定怎么渲染,不能再假设清一色是 message。 |
Runs | Responses | 改动最大的一格。异步任务变成一次请求:送进 input items、拿回 output items。工具调用循环改为显式自己管——原来等 requires_action 再提交工具输出的那段,换成自己写的 while。 |
Run steps | Items | 泛化成统一的 item 对象。原来靠列步骤做排查的地方,改成遍历 output item。 |
while,迁移就完成了七成。
2.7 这套抽象在别处仍然活着
四对象不是 OpenAI 的私产,它是托管式 Agent 的通用形状。国产平台的接口路径几乎逐字对应:
| 概念 | 典型接口路径(拼 HTTP 那一派) | 典型 SDK 写法(那一派) |
|---|---|---|
| Assistant | /v1/assistants/create | Assistants.create(...) |
| Thread | /v1/threads/create | Threads.create() |
| Message | /v1/threads/messages/add | Messages.create(thread_id, ...) |
| Run | /v1/threads/run/create | Runs.create(thread_id, assistant_id=...) |
| 轮询 | /v1/threads/run/retrieve | Runs.wait(run_id, thread_id=...) |
| Run Step | (部分平台不单独暴露) | Steps.list(run_id, thread_id=...) |
| 取回复 | /v1/threads/messages/list | Messages.list(thread_id) |
所以吃透四对象是一笔可迁移的投资:换平台时,你要改的是 base url、鉴权头、字段名,流程一步不少、一步不多。第 04 节会把这两派写法各跑一遍。
03最小代码:两条路各走一遍最短距离
托管式四对象最短版、Responses 最短版,以及把两段并排读出来的差异
3.1 托管式四对象:能跑通的最短版本
先把最短路径跑通,再谈工程化。这份脚本只做一件事:建助手、建线程、放一封信、跑一次、轮询、读回信——四个对象各出场一次,没有文件上传、没有工具、没有重试。
跑之前先把两个环境变量准备好。密钥与租户编号在平台控制台里找到密钥管理相关的功能即可获取,获取后只写进环境变量,不要写进代码:
"""minimax_min.py —— 托管式接口的最小可跑版本:四个对象走一遍。
去掉文件上传、去掉工具、去掉重试,只留下让四对象心智模型立起来的最短路径:
建 Assistant(岗位说明书)
建 Thread(档案袋)
放 Message(信件)
跑 Run(照着档案办)→ 轮询 → 读回信
先把这 40 行跑通,再去看 minimax_assistant.py 的完整版。
export MINIMAX_API_KEY='...'
export MINIMAX_GROUP_ID='...'
python3 minimax_min.py
"""
import json
import time
import requests
from envkit import need_env, opt_env
KEY = need_env("MINIMAX_API_KEY", "MiniMax 开放平台密钥")
GID = need_env("MINIMAX_GROUP_ID", "MiniMax 的 GroupId")
BASE = opt_env("MINIMAX_BASE", "https://api.minimax.chat")
H = {"Authorization": "Bearer %s" % KEY, "Content-Type": "application/json"}
def post(path, body=None):
"""所有写操作都是 POST + GroupId 查询参数 + JSON 体。"""
url = "%s/v1/%s?GroupId=%s" % (BASE, path, GID)
resp = requests.post(url, headers=H, data=json.dumps(body or {}), timeout=60)
resp.raise_for_status()
return resp.json()
# 1. Assistant:一次创建,可以被无数个 Thread 反复使用
assistant = post("assistants/create", {
"model": "abab5.5-chat",
"name": "算术助手",
"instructions": "你是一个严谨的算术助手,回答时先写计算过程再写结果",
})
print("assistant:", assistant["id"])
# 2. Thread:一个客户一个档案袋,本身不含任何配置
thread = post("threads/create")
print("thread:", thread["id"])
# 3. Message:把问题作为一封信存进袋子,这一步模型还没动
post("threads/messages/add", {
"thread_id": thread["id"],
"role": "user",
"content": "一斤苹果3元,我买了2.5斤,一共多少钱",
})
# 4. Run:喊一声开工,立刻返回,事情在平台那边异步跑
run = post("threads/run/create", {
"thread_id": thread["id"],
"assistant_id": assistant["id"],
})
print("run:", run["id"])
# 5. 轮询:最小版用固定间隔,生产环境务必换成指数退避(见 retrykit.py)
while True:
url = "%s/v1/threads/run/retrieve?GroupId=%s" % (BASE, GID)
body = json.dumps({"thread_id": thread["id"], "run_id": run["id"]})
status = requests.request("GET", url, headers=H, data=body, timeout=60) \
.json().get("status", "")
print("status:", status)
if status in ("completed", "failed", "cancelled", "expired"):
break
time.sleep(2)
# 6. 读回信:助手写的内容也在同一个档案袋里
url = "%s/v1/threads/messages/list?GroupId=%s" % (BASE, GID)
out = requests.get(url, headers=H,
data=json.dumps({"thread_id": thread["id"]}), timeout=60).json()
print(json.dumps(out, indent=2, ensure_ascii=False))
逐段说明这段代码在干什么:
| 代码位置 | 对应对象 | 要点 |
|---|---|---|
need_env(...) | —— | 密钥一律从环境变量读;缺失时抛出一句人能看懂的话,而不是等到 401 再猜 |
post("assistants/create") | Assistant | 模型 + 名字 + instructions。这一步可以只做一次,把 id 存进自己的配置,不要每次请求都建一个新助手 |
post("threads/create") | Thread | 请求体是空的——档案袋不含任何配置,它只是个容器 |
post("threads/messages/add") | Message | 注意这一步不会返回模型的回答,它只是存档 |
post("threads/run/create") | Run | 立刻返回 run_id;这时候去列消息,大概率还只有你刚塞进去那一封 |
while 轮询 | Run 状态 | 最小版用固定间隔是为了看清结构,生产环境换成退避(见 2.3) |
messages/list | Message | 回到袋子里取信。最新那条 role 为 assistant 的消息才是回复 |
requests.get(...) 加 data= 能发出去,但有些 HTTP 客户端和网关会把 GET 的 body 丢掉。遇到「参数明明传了,服务端说缺参数」,先怀疑这一点,改用 requests.request("GET", url, data=...) 或按平台文档换成查询参数。
3.2 Responses:同一件事的最短版本
换到继任接口上,同样一件事短成这样:
"""responses_min.py —— Responses API 的最小调用。
OpenAI 侧接替托管式助手接口的是 Responses API。它把「一次运行」
直接做成了一个 response 对象:送进去 input,拿回来 output。
没有先建助手、再建线程、再建运行这三级台阶。
export OPENAI_API_KEY='...'
python3 responses_min.py
"""
from openai import OpenAI
from envkit import MissingEnv, need_env, opt_env
def main():
client = OpenAI(
api_key=need_env("OPENAI_API_KEY", "OpenAI 密钥"),
base_url=opt_env("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
# 一次调用 = 旧模型里的「建 Thread + 加 Message + 跑 Run + 读回信」
resp = client.responses.create(
model="gpt-4.1-mini",
instructions="你是一个严谨的算术助手,先写计算过程再写结果",
input="一斤苹果3元,我买了2.5斤,一共多少钱",
)
# output_text 是 SDK 给的便捷属性,把所有文本型 output item 拼起来。
# 真正的结构在 resp.output 里,是一个 item 列表。
print("回复:", resp.output_text)
print("id:", resp.id)
# 把 item 摊开看一眼,能直观感受到「Run step 变成了 item」这句话:
for item in resp.output:
print("item type =", getattr(item, "type", "?"))
if __name__ == "__main__":
try:
main()
except MissingEnv as exc:
raise SystemExit(str(exc))
一次 responses.create() = 旧模型里的「建 Thread + 加 Message + 跑 Run + 读回信」。没有轮询、没有状态机、没有第二次网络往返。instructions 这个参数承接了旧 Assistant 的岗位说明书职责,只不过它跟着每次请求走,不再是服务端的一个对象。
output_text 是 SDK 提供的便捷属性:把所有文本型 output item 拼起来。真正的结构在 resp.output 里,是一个 item 列表——把它打印出来,「Run step 变成了 item」这句话就有了实感。
3.3 两段并排读:差异只在三处
| 环节 | 托管式四对象 | Responses |
|---|---|---|
| 配置在哪 | 服务端对象 assistant_id,建一次反复用 | 随请求传的 instructions,或控制台里备好的 Prompts |
| 会话在哪 | thread_id,隐式带上全部历史 | 可选的 conversation;不传就是一次性问答 |
| 怎么拿结果 | 创建 Run → 轮询到终态 → 回 Thread 列消息 | 同步返回,resp.output 就是结果 |
| 网络往返 | 六次起步 | 一次 |
| 工具循环谁写 | 平台写;停在 requires_action 等你交作业 | 你自己写 while |
| 代码行数 | 约 60 行(含轮询) | 约 15 行 |
短不等于弱:省下来的六次往返,换的是「多轮上下文要不要自己接一个 conversation」「工具循环要不要自己写」这两件事重新回到你手上。这正是换代的核心取舍——把便利换成控制权。第 04 节的四个案例,就是沿着这条取舍线依次往右走:托管到底 → 迁移到新接口 → 状态全部拿回自己家。
04完整案例:同一个助手,四种活法
水果店收银助手从托管式全流程起步,依次走到换平台、迁新接口、全部自建,最后补一次退出演练
四个案例围绕同一个业务需求展开,方便横向对比:一家水果店,记录了每种水果的成本价与售价,老板想问「我卖了 2 斤葡萄、3 斤半香蕉、2 斤苹果,总成本和总收入各是多少」,助手要给出计算过程。
价目数据放在一个纯文本文件里(虚构数据,只用来验证检索能不能把内容找回来):
| 水果 | 成本价 | 售价 |
|---|---|---|
| 香蕉 | 2 元一斤 | 3 元一斤 |
| 橘子 | 1.5 元一斤 | 2.5 元一斤 |
| 苹果 | 3 元一斤 | 3.5 元一斤 |
| 芒果 | 5 元一斤 | 6 元一斤 |
| 葡萄 | 2 元一斤 | 4 元一斤 |
同一个需求,下面四个案例沿着三条路线依次往右走:先把状态完全托管出去(案例一、二),再迁到新接口(案例三),最后把状态全部拿回自己家(案例四)。先把三条路线的差别摄在一张图里:
一个容易被念反的结论:这三条路不是技术水平的高低。选哪条取决于两件事:这套东西要活多久,以及数据能不能留在自己手里。
4.1 案例一:托管式全流程(上传文件 + 检索 + 轮询)
这是托管式路线的完整形态,比 3.1 的最小版多了三件在真实项目里必须有的东西:文件上传(让助手能检索业务资料)、双重返回值检查、退避轮询。
流程一共七步:
把价目表传给平台,拿到 file_id。purpose 声明这个文件给谁用,助手检索场景固定填 assistants。
模型、名字、描述、instructions,挂上 file_ids,并在 tools 里启用 retrieval。
空袋子,只拿 id。
把老板的问题作为一封 user 信件存进袋子。
喊一声开工,拿到 run_id。
退避着查到终态;非 completed 的终态一律当失败处理。
列出袋子里的全部消息,最新那条 assistant 消息就是答复。
每一步都检查返回值;任一步失败立刻抛错并说清是哪一步,不要带着空 id 往下跑。
"""envkit.py —— 读取环境变量的公共小工具。
托管式接口的鉴权信息(API-KEY、GroupId、base_url)全部从环境变量读,
源码里永远不出现明文。缺失时抛出一句人能看懂的话,而不是让程序在
几十行之后因为 401 才炸掉。
用法:
from envkit import need_env, opt_env
API_KEY = need_env("MINIMAX_API_KEY", "MiniMax 开放平台的 API-KEY")
BASE = opt_env("MINIMAX_BASE", "https://api.minimax.chat")
"""
import os
class MissingEnv(RuntimeError):
"""环境变量缺失。单独定义一个异常类型,方便上层统一兜底提示。"""
def need_env(name, what=""):
"""取一个必须存在的环境变量;没有就抛出带指引的异常。
注意用 os.environ.get 而不是 os.environ[name]:
前者能让我们自己控制报错文案,后者只会抛一个光秃秃的 KeyError。
"""
val = os.environ.get(name)
if not val:
hint = ("(%s)" % what) if what else ""
raise MissingEnv(
"环境变量 %s 未设置%s。\n"
" 临时设置:export %s='你的取值'\n"
" 或写进项目根目录的 .env,再用 python-dotenv 加载。"
% (name, hint, name)
)
return val.strip()
def opt_env(name, default):
"""取一个可选的环境变量,缺失时用默认值。"""
val = os.environ.get(name)
return val.strip() if val else default
def mask(secret):
"""把密钥打码后再打印。日志、异常、截图里都不该出现完整密钥。"""
if not secret:
return "(空)"
if len(secret) <= 8:
return "*" * len(secret)
return secret[:4] + "*" * (len(secret) - 8) + secret[-4:]
if __name__ == "__main__":
# 自检:把当前环境里几个常用变量的存在情况打出来,值一律打码。
for key in ("MINIMAX_API_KEY", "MINIMAX_GROUP_ID",
"DASHSCOPE_API_KEY", "OPENAI_API_KEY"):
raw = os.environ.get(key)
print("%-20s %s" % (key, mask(raw) if raw else "未设置"))
"""minimax_assistant.py —— 托管式 Agent 接口的完整七步:水果店收银助手。
MiniMax 开放平台提供的这套接口,对象模型与 Assistant / Thread / Message / Run
完全一致:助手配置存在平台上,会话档案袋存在平台上,跑一次叫 Run,
状态要自己轮询。这份脚本把七步一次跑完:
上传文件 → 创建 Assistant → 创建 Thread → 添加 Message
→ 创建 Run → 轮询状态 → 取回消息列表
运行前准备:
export MINIMAX_API_KEY='...' # 平台控制台里找到密钥管理功能获取
export MINIMAX_GROUP_ID='...' # 同一处可以找到 GroupId
同目录准备 fruit_price.txt,内容形如:
香蕉成本价2元一斤,售价为3元一斤。
橘子成本价1.5元一斤,售价为2.5元一斤
苹果成本价3元一斤,售价为3.5元一斤
芒果成本价5元一斤,售价为6元一斤
葡萄成本价2元一斤,售价为4元一斤
(虚构数据,只用来演示 retrieval 工具能不能把文件内容找回来。)
"""
import json
import os
import requests
from envkit import MissingEnv, need_env, opt_env
from retrykit import poll_until
API_KEY = None # 延迟到 main() 里读,导入本模块不应该直接报错
GROUP_ID = None
BASE = opt_env("MINIMAX_BASE", "https://api.minimax.chat")
PRICE_FILE = opt_env("FRUIT_PRICE_FILE", "./fruit_price.txt")
TIMEOUT = 60 # 单次 HTTP 超时,秒
def _headers(json_body=True):
"""鉴权头。上传文件走 multipart,不能自己写 Content-Type,
否则 requests 无法补上 multipart 的 boundary,服务端直接解析失败。"""
head = {"Authorization": "Bearer %s" % API_KEY}
if json_body:
head["Content-Type"] = "application/json"
return head
def _url(path):
"""所有接口都要带 GroupId 查询参数,集中拼一次,省得每个函数重复。"""
return "%s/v1/%s?GroupId=%s" % (BASE, path, GROUP_ID)
def _check(resp, step):
"""统一的返回体检查:HTTP 码 + 业务 base_resp 双重判断。
托管式接口常见的坑是 HTTP 200 但业务码非 0,
只看 status_code 会把失败当成功,一路跑到最后才发现 id 是空串。
"""
if resp.status_code != 200:
raise RuntimeError("%s 失败:HTTP %s %s"
% (step, resp.status_code, resp.text[:300]))
data = resp.json()
base = data.get("base_resp") or {}
if base and base.get("status_code") not in (0, None):
raise RuntimeError("%s 失败:业务码 %s %s"
% (step, base.get("status_code"), base.get("status_msg")))
return data
# ---------- 第 0 步:上传文件 ----------
def create_file(path):
"""把本地文件传给平台,拿到 file_id。
purpose 声明这个文件给谁用;助手检索场景固定填 assistants。
用 with 打开文件,避免句柄泄漏。
"""
if not os.path.exists(path):
raise FileNotFoundError("找不到 %s,先在同目录准备好这个文件" % path)
with open(path, "rb") as fh:
resp = requests.post(_url("files/upload"),
headers=_headers(json_body=False),
data={"purpose": "assistants"},
files={"file": fh},
timeout=TIMEOUT)
data = _check(resp, "上传文件")
file_id = (data.get("file") or {}).get("file_id")
if not file_id:
raise RuntimeError("上传成功但没拿到 file_id:%s" % json.dumps(data)[:300])
return str(file_id)
# ---------- 第 1 步:创建 Assistant ----------
def create_assistant(file_id):
"""创建助手:模型、名字、描述、人设指令、挂载的文件、启用的工具。
instructions 是这个岗位的说明书,它对这个助手的每一次 Run 都生效;
tools 里的 retrieval 让助手可以在挂载文件里检索。
"""
payload = {
"model": "abab5.5-chat",
"name": "水果店财务助手",
"description": "水果店财务助手,用在水果销售过程中计算营业额",
"instructions": "你是一个理财能手,根据每类水果的售出量以及单价,"
"统计其成本和收入,计算出总利润,并给出计算过程",
"file_ids": [file_id],
"tools": [{"type": "retrieval"}],
}
resp = requests.post(_url("assistants/create"), headers=_headers(),
data=json.dumps(payload), timeout=TIMEOUT)
data = _check(resp, "创建助手")
return data["id"]
# ---------- 第 2 步:创建 Thread ----------
def create_thread():
"""创建一个会话档案袋。它是空的,只有一个 id,
之后这一位客户的所有来往信件都按顺序装进这个袋子。"""
resp = requests.post(_url("threads/create"), headers=_headers(), timeout=TIMEOUT)
return _check(resp, "创建线程")["id"]
# ---------- 第 3 步:往 Thread 里放 Message ----------
def add_message(thread_id, content, role="user"):
"""往档案袋里塞一张信件。这一步不会触发模型,只是存档。"""
payload = {"thread_id": thread_id, "role": role, "content": content}
resp = requests.post(_url("threads/messages/add"), headers=_headers(),
data=json.dumps(payload), timeout=TIMEOUT)
return _check(resp, "添加消息")
# ---------- 第 4 步:创建 Run ----------
def create_run(thread_id, assistant_id):
"""喊一声「照着档案办」。请求立刻返回一个 run_id,事情还没办完。"""
payload = {"thread_id": thread_id, "assistant_id": assistant_id}
resp = requests.post(_url("threads/run/create"), headers=_headers(),
data=json.dumps(payload), timeout=TIMEOUT)
return _check(resp, "创建运行")["id"]
# ---------- 第 5 步:轮询 Run 状态 ----------
def fetch_run_status(thread_id, run_id):
"""查一次运行状态,返回状态字符串。"""
payload = json.dumps({"thread_id": str(thread_id), "run_id": str(run_id)})
resp = requests.request("GET", _url("threads/run/retrieve"),
headers=_headers(), data=payload, timeout=TIMEOUT)
return _check(resp, "查询运行状态").get("status", "")
def wait_run(thread_id, run_id, timeout=180):
"""指数退避轮询到终态。失败终态照样返回,交给上层判断,不要当成功。"""
terminal = {"completed", "failed", "cancelled", "expired", "requires_action"}
return poll_until(
lambda: fetch_run_status(thread_id, run_id),
terminal=terminal,
timeout=timeout,
on_tick=lambda n, s, t: print(" 第 %d 次查询:status=%s(已等待 %.1fs)" % (n, s, t)),
)
# ---------- 第 6 步:取回消息 ----------
def list_messages(thread_id):
"""把档案袋里现在所有的信件按顺序拉回来,助手的回复也在里面。"""
payload = json.dumps({"thread_id": thread_id})
resp = requests.get(_url("threads/messages/list"), headers=_headers(),
data=payload, timeout=TIMEOUT)
return _check(resp, "拉取消息列表")
def main():
global API_KEY, GROUP_ID
API_KEY = need_env("MINIMAX_API_KEY", "MiniMax 开放平台密钥")
GROUP_ID = need_env("MINIMAX_GROUP_ID", "MiniMax 的 GroupId")
print("[0/6] 上传文件 …")
file_id = create_file(PRICE_FILE)
print(" file_id =", file_id)
print("[1/6] 创建 Assistant …")
assistant_id = create_assistant(file_id)
print(" assistant_id =", assistant_id)
print("[2/6] 创建 Thread …")
thread_id = create_thread()
print(" thread_id =", thread_id)
print("[3/6] 添加 Message …")
add_message(thread_id,
"我卖了2斤葡萄,3斤半的香蕉,2斤苹果,"
"计算下总成本和总收入,给出具体的计算过程")
print("[4/6] 创建 Run …")
run_id = create_run(thread_id, assistant_id)
print(" run_id =", run_id)
print("[5/6] 轮询状态 …")
status = wait_run(thread_id, run_id)
if status != "completed":
raise RuntimeError("Run 以 %s 结束,没有可用回复" % status)
print("[6/6] 拉取消息 …")
msgs = list_messages(thread_id)
print(json.dumps(msgs, indent=2, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except MissingEnv as exc:
raise SystemExit(str(exc))
几处值得单独说的实现细节
上传走 multipart,手动设置 Content-Type: application/json 会让 requests 无法补上 multipart 的 boundary,服务端直接解析失败。所以 _headers(json_body=False) 单独准备了一份不带该字段的请求头。
这类接口常见 HTTP 200 但业务码非 0。只看 status_code 会把失败当成功,一路跑到最后才发现 id 是空串。_check() 同时看 HTTP 码和业务返回里的状态字段。
挂了文件的助手,平台要做向量化与存储,刚建好就跑 Run 有可能检索不到内容。稳妥做法不是盲目 sleep,而是先去查助手状态确认就绪,或在 Run 失败时按退避重试。
file_id、assistant_id、thread_id、run_id 是你和平台之间唯一的对账凭据。打日志、进数据库,缺一个后面都查不下去。
输出长什么样
跑通之后,最后一步打印的是整个消息列表的 JSON。助手写的那条内容大意如下(换一次模型、换一次提问,措辞都会变,但结构是固定的:先列价格,再逐项算,最后汇总):
总成本 = 2×2 + 3.5×2 + 2×3 = 4 + 7 + 6 = 17 元;
总收入 = 2×4 + 3.5×3 + 2×3.5 = 8 + 10.5 + 7 = 25.5 元;
总利润 = 25.5 − 17 = 8.5 元。
注意这段回复里的价格来自上传的文件,不是模型背下来的——这就是 Retrieval 工具在起作用。把 tools 里的 retrieval 去掉再跑一遍,模型就只能用提问里出现过的数字,或者干脆编一个。
4.2 案例二:换一家平台的 SDK 写法
案例一是自己拼 HTTP 请求,另一派平台提供封装好的 SDK。把同一个助手用 SDK 再写一遍,能看清一件事:四对象是抽象,不是某一家的接口细节。
"""dashscope_assistant.py —— 同一套四对象,换一家平台的 SDK 写法。
MiniMax 那份是自己拼 HTTP 请求,这份用阿里 DashScope 的 SDK。
对比着看能看清一件事:**四对象是抽象,不是某一家的接口细节**。
换平台变的是函数名和字段名,不变的是
Assistant → Thread → Message → Run → Run Step → Message
这条链路。
pip install dashscope
export DASHSCOPE_API_KEY='...'
python3 dashscope_assistant.py
"""
import json
import sys
from http import HTTPStatus
import dashscope
from envkit import MissingEnv, need_env
QUESTION = (
"香蕉成本价2元一斤,售价为3元一斤;苹果成本价3元一斤,售价为3.5元一斤;"
"葡萄成本价2元一斤,售价为4元一斤。我卖了2斤葡萄、3.5斤香蕉、2斤苹果,"
"计算总成本和总收入,给出具体计算过程"
)
def verify(res, step):
"""SDK 把 HTTP 状态码放在返回对象上,不抛异常,所以每步都要自己查。
漏掉这一步的典型症状:前面某一步其实失败了,
后面拿 res.id 得到 None,报错却出现在毫不相干的地方。
"""
if res.status_code != HTTPStatus.OK:
print("[%s] 失败:%s" % (step, res), file=sys.stderr)
sys.exit(res.status_code)
return res
def create_assistant():
"""岗位说明书:模型、名字、描述、instructions、可用工具。
tools 里填平台自己的内置工具类型;不同平台支持的工具类型不同,
这也是托管式接口迁移时最先崩掉的地方。
"""
return dashscope.Assistants.create(
model="qwen-max",
name="水果店财务助手",
description="用在水果销售过程中计算营业额",
instructions="你是一个理财能手,根据每类水果的售出量以及单价,"
"统计其成本和收入,计算出总利润",
tools=[{"type": "quark_search"}],
)
def main():
# SDK 的鉴权是给模块级变量赋值;密钥仍然只从环境变量来
dashscope.api_key = need_env("DASHSCOPE_API_KEY", "阿里云百炼的 API-KEY")
assistant = verify(create_assistant(), "创建 Assistant")
print("assistant:", assistant.id)
thread = verify(dashscope.Threads.create(), "创建 Thread")
print("thread:", thread.id)
message = verify(dashscope.Messages.create(thread.id, content=QUESTION),
"添加 Message")
print("message:", message.id)
run = verify(dashscope.Runs.create(thread.id, assistant_id=assistant.id),
"创建 Run")
print("run:", run.id, "初始状态:", run.status)
# SDK 自带的 wait 就是一个封装好的轮询;它会在 completed 或
# requires_action 时返回。requires_action 意味着「助手要你去执行工具」,
# 这时候循环还没结束,得提交工具输出之后再继续等。
run = verify(dashscope.Runs.wait(run.id, thread_id=thread.id), "等待 Run")
print("终态:", run.status)
steps = verify(dashscope.Steps.list(run.id, thread_id=thread.id), "列出 Run Step")
print("run steps:", steps)
msgs = verify(dashscope.Messages.list(thread.id), "列出 Message")
print(json.dumps(msgs, default=lambda o: o.__dict__,
sort_keys=True, indent=2, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except MissingEnv as exc:
raise SystemExit(str(exc))
| 环节 | 拼 HTTP 那一派 | SDK 那一派 |
|---|---|---|
| 鉴权 | 每个请求带 Authorization 头 | 给 SDK 的模块级变量赋值一次 |
| 建助手 | POST /v1/assistants/create | Assistants.create(model=..., tools=[...]) |
| 错误处理 | 看 HTTP 码 + 业务码 | SDK 把状态码挂在返回对象上,不抛异常,每步都要自己查 |
| 轮询 | 自己写循环 | Runs.wait(...) 已封装好,但它也会在 requires_action 时返回 |
| 看流水 | 部分平台不暴露 | Steps.list(run_id, thread_id=...) |
| 工具类型 | 平台自定义 | 平台自定义——这是迁移时最先崩的地方 |
res.id 得到 None,报错却出现在毫不相干的地方。所以代码里每一步都套了 verify(),并把步骤名一起打出来。
再看一眼两家的工具类型:一家叫 retrieval,另一家提供的是自家搜索工具、文生图工具。工具清单是平台专有的,四对象流程能平移,工具能力不能。做选型时,这一栏要单独列出来评估。
4.3 案例三:迁到 Responses + Conversations
现在把同一个助手迁到继任接口上。先看多轮会话怎么接:
"""responses_conversation.py —— Responses + Conversations:多轮会话的等价写法。
托管式助手接口里,多轮对话靠 Thread:消息存在平台上,每次 Run
自动带上整袋历史。迁到 Responses 之后,这件事由 Conversations 承担:
先建一个 conversation,之后每次 create 都带上它的 id,
平台照样替你保存上下文。
对照关系:
建 Thread → client.conversations.create()
往 Thread 加 Message → 带 conversation 参数发一次 responses.create()
跑 Run → responses.create() 本身
列 Thread 的消息 → client.conversations.items.list(conversation_id)
一个关键差别:conversation 里存的不再只是 message,
而是 item —— 工具调用、工具输出、模型回复全是 item。
export OPENAI_API_KEY='...'
python3 responses_conversation.py
"""
from openai import OpenAI
from envkit import MissingEnv, need_env, opt_env
def main():
client = OpenAI(
api_key=need_env("OPENAI_API_KEY", "OpenAI 密钥"),
base_url=opt_env("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
# 1. 建一个会话容器,相当于以前的 Thread
conv = client.conversations.create()
print("conversation:", conv.id)
# 2. 第一轮。instructions 相当于以前写在 Assistant 上的岗位说明书,
# 现在跟着每次请求走 —— 这既是灵活,也是负担:
# 它不再有平台侧的唯一版本,得由你自己保证每次都传对。
first = client.responses.create(
model="gpt-4.1-mini",
conversation=conv.id,
instructions="你是一个耐心的数学老师,回答简短,必要时列式",
input="一斤苹果3元,我买了2.5斤,一共多少钱",
)
print("第一轮:", first.output_text)
# 3. 第二轮只发新问题。历史由 conversation 带着,
# 不需要像纯 Chat Completions 那样自己把整个 messages 数组重发一遍。
second = client.responses.create(
model="gpt-4.1-mini",
conversation=conv.id,
input="那如果再加两斤香蕉,香蕉一斤3元呢",
)
print("第二轮:", second.output_text)
# 4. 把会话里的 item 列出来,看看平台替我们存了什么
items = client.conversations.items.list(conv.id)
for it in items.data:
print("item:", getattr(it, "type", "?"), getattr(it, "role", ""))
if __name__ == "__main__":
try:
main()
except MissingEnv as exc:
raise SystemExit(str(exc))
对照关系一目了然:建 Thread 变成 conversations.create();「加 Message + 跑 Run」合并成一次 responses.create();列 Thread 的消息变成列 conversation 的 item。
然后是改动最大的那一块——工具调用循环。托管式接口里,循环是平台的;迁过来之后,循环是你 while 写出来的:
"""responses_tool_loop.py —— 工具调用循环改成显式自己管。
这是迁移里改动最大的一处。
旧写法(托管式 Run):提交 Run 之后轮询状态,平台在内部替你
反复调模型;只有轮到「需要你执行本地函数」时,Run 才会停在
requires_action 上等你提交工具输出。循环是平台的。
新写法(Responses):模型要调工具时,直接在 output 里给你
function_call item;你执行完,把 function_call_output 作为
新的 input item 再发一次。**循环是你的 while 写的。**
代价是多写十几行,好处是这十几行完全在你手里 ——
换任何一家兼容 tool calling 的模型,这段循环都不用重写。
export OPENAI_API_KEY='...'
python3 responses_tool_loop.py
"""
import json
from openai import OpenAI
from envkit import MissingEnv, need_env, opt_env
# ---------- 你的真实函数 ----------
PRICE = {"香蕉": 3.0, "橘子": 2.5, "苹果": 3.5, "芒果": 6.0, "葡萄": 4.0}
COST = {"香蕉": 2.0, "橘子": 1.5, "苹果": 3.0, "芒果": 5.0, "葡萄": 2.0}
def lookup_fruit(name):
"""查一种水果的成本价与售价。查不到也要正常返回,别抛异常 ——
异常会打断循环,而「查无此水果」是模型需要知道的事实。"""
if name not in PRICE:
return {"found": False, "fruit": name, "msg": "价目表里没有这一项"}
return {"found": True, "fruit": name, "cost": COST[name], "price": PRICE[name]}
TOOLS = [{
"type": "function",
"name": "lookup_fruit",
"description": "按水果名查询它的成本价与售价,单位是元每斤",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "水果名称,例如 苹果"},
},
"required": ["name"],
},
}]
REGISTRY = {"lookup_fruit": lookup_fruit}
def main():
client = OpenAI(
api_key=need_env("OPENAI_API_KEY", "OpenAI 密钥"),
base_url=opt_env("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
conv = client.conversations.create()
# input 是一个 item 列表;第一轮只有用户那一条
pending = [{"role": "user",
"content": "我卖了2斤葡萄、3.5斤香蕉、2斤苹果,总成本和总收入各是多少"}]
for turn in range(1, 9): # 上限保护,防止死循环
resp = client.responses.create(
model="gpt-4.1-mini",
conversation=conv.id,
instructions="你是水果店财务助手,需要价格时调用工具,不要自己编价格",
tools=TOOLS,
input=pending,
)
# 挑出这一轮模型要求执行的函数调用
calls = [it for it in resp.output if getattr(it, "type", "") == "function_call"]
if not calls:
print("第 %d 轮:模型给出最终答复" % turn)
print(resp.output_text)
return
print("第 %d 轮:模型要求执行 %d 个函数" % (turn, len(calls)))
pending = []
for call in calls:
fn = REGISTRY.get(call.name)
if fn is None:
# 模型编了一个不存在的函数名。把这件事如实告诉它,
# 比直接抛异常更容易让它自己纠正。
result = {"error": "未注册的函数 %s" % call.name}
else:
result = fn(**json.loads(call.arguments))
print(" %s(%s) -> %s" % (call.name, call.arguments, result))
# 回填的 item 类型是 function_call_output,靠 call_id 和请求配对
pending.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result, ensure_ascii=False),
})
print("达到轮次上限仍未收敛,检查工具描述是否含糊")
if __name__ == "__main__":
try:
main()
except MissingEnv as exc:
raise SystemExit(str(exc))
| 动作 | 托管式(旧) | Responses(新) |
|---|---|---|
| 模型要求调函数 | Run 进入 requires_action | output 里出现 function_call 类型的 item |
| 你执行函数 | 完全一样——执行的永远是你的代码 | |
| 回传结果 | 调「提交工具输出」接口,带 tool_call_id | 把 function_call_output 作为新的 input item 再发一次,带 call_id |
| 继续下一轮 | Run 自动回到 in_progress | 你的 while 再转一圈 |
| 什么时候停 | Run 进入终态 | output 里不再出现 function_call |
| 防死循环 | 平台侧有上限 | 自己写 for turn in range(...) 兜底 |
把旧写法和新写法并排放在一个文件里,迁移要改哪几行最直观:
"""migrate_side_by_side.py —— 同一个需求,两种写法并排放。
需求:让一个「严谨算术助手」回答两轮追问,并把会话留在服务端。
上半部分是托管式四对象写法(伪代码形态,接口已不可用,留着是为了对照);
下半部分是 Responses + Conversations 的可运行写法。
把两段并排读一遍,迁移要改哪几行就一目了然。
export OPENAI_API_KEY='...'
python3 migrate_side_by_side.py
"""
from openai import OpenAI
from envkit import MissingEnv, need_env, opt_env
INSTRUCTIONS = "你是一个严谨的算术助手,先写计算过程再写结果"
Q1 = "一斤苹果3元,我买了2.5斤,一共多少钱"
Q2 = "再加两斤香蕉,香蕉一斤3元,一共多少钱"
# ---------------------------------------------------------------- 旧写法
def old_way_pseudocode():
"""托管式四对象的形状。这段不要运行,接口已经不存在了。
assistant = client.beta.assistants.create( # 岗位说明书存在平台
model="gpt-4-turbo",
name="算术助手",
instructions=INSTRUCTIONS,
)
thread = client.beta.threads.create() # 档案袋存在平台
client.beta.threads.messages.create( # 第一封信
thread_id=thread.id, role="user", content=Q1)
run = client.beta.threads.runs.create( # 喊一声开工
thread_id=thread.id, assistant_id=assistant.id)
run = wait_until_terminal(run) # 轮询到终态
msgs = client.beta.threads.messages.list(thread.id)
client.beta.threads.messages.create( # 第二封信,同一个袋子
thread_id=thread.id, role="user", content=Q2)
run = client.beta.threads.runs.create(
thread_id=thread.id, assistant_id=assistant.id)
要点:配置(assistant)与会话(thread)是两个独立的服务端对象,
运行(run)是异步任务,工具调用循环由平台内部完成。
"""
return old_way_pseudocode.__doc__
# ---------------------------------------------------------------- 新写法
def new_way():
client = OpenAI(
api_key=need_env("OPENAI_API_KEY", "OpenAI 密钥"),
base_url=opt_env("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
# Thread → Conversation:会话容器还在服务端,换了个名字和语义
conv = client.conversations.create()
# Message + Run → 一次 responses.create():
# 送进 input item,同步拿回 output item,不再有 run 状态机
first = client.responses.create(
model="gpt-4.1-mini",
conversation=conv.id,
instructions=INSTRUCTIONS, # 配置跟着请求走,不再是平台上的对象
input=Q1,
)
print("第一轮:", first.output_text)
second = client.responses.create(
model="gpt-4.1-mini",
conversation=conv.id,
instructions=INSTRUCTIONS,
input=Q2,
)
print("第二轮:", second.output_text)
# Thread 的 message 列表 → Conversation 的 item 列表
for it in client.conversations.items.list(conv.id).data:
print("item:", getattr(it, "type", "?"), getattr(it, "role", ""))
MAPPING = [
("Assistants", "Prompts", "配置可版本化、可回滚;在控制台里创建,不通过接口建"),
("Threads", "Conversations", "从只存 message 变成存 item"),
("Runs", "Responses", "异步任务变成一次请求;工具调用循环由自己写"),
("Run steps", "Items", "泛化成统一的 item 对象"),
]
def print_mapping():
print("%-12s %-16s %s" % ("旧对象", "新对象", "变化"))
for old, new, why in MAPPING:
print("%-12s %-16s %s" % (old, new, why))
if __name__ == "__main__":
print_mapping()
print()
try:
new_way()
except MissingEnv as exc:
print("(未配置密钥,跳过实际调用)")
print(exc)
requires_action 的分支重写成 while 取 function_call;⑤ 读历史的地方加上 item 类型判断;⑥ 排查用的步骤列表换成遍历 output item。
4.4 案例四:不依赖任何托管——会话表 + 工具循环
前面三条路都有同一个前提:状态在别人家。这一条把它搬回来。要补的东西其实只有三张表:
| 表 | 一行是什么 | 关键字段 |
|---|---|---|
agents | 一个 Assistant | 模型、instructions、工具清单——岗位说明书进了自己的库,顺带就有了版本控制的可能 |
threads | 一个档案袋 | 归属的 agent、标题、创建时间 |
messages | 一封信 | seq 保序、role、content,外加一个 extra_json 原样存 tool_calls / tool_call_id |
"""selfhost_store.py —— 把档案袋搬回自己家:SQLite 版会话存储。
托管式接口替你保管 Thread 和 Message,代价是这些数据在别人手里,
接口下线那天一起消失。自己存其实只要三张表:
agents 一行 = 一个 Assistant(岗位说明书:模型、指令、工具清单)
threads 一行 = 一个会话档案袋
messages 一行 = 袋子里的一封信,按 seq 排序
这份实现不依赖任何第三方库,标准库 sqlite3 就够;
换成 MySQL / PostgreSQL 时,只有建表语句和占位符要改。
python3 selfhost_store.py # 直接运行会跑一段自检
"""
import json
import os
import sqlite3
import time
import uuid
DB_PATH = os.environ.get("AGENT_DB", "./agent_state.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
model TEXT NOT NULL,
instructions TEXT NOT NULL,
tools_json TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
title TEXT,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL, -- system / user / assistant / tool
content TEXT,
extra_json TEXT, -- tool_calls、tool_call_id 等原样存这里
created_at REAL NOT NULL
);
-- 按 thread 取消息是最高频的查询,这条索引不能省
CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id, seq);
"""
def _uid(prefix):
"""自己发 id。格式模仿托管式接口,迁移时对账方便。"""
return "%s_%s" % (prefix, uuid.uuid4().hex[:24])
class Store:
def __init__(self, path=DB_PATH):
self.conn = sqlite3.connect(path)
self.conn.row_factory = sqlite3.Row
self.conn.executescript(SCHEMA)
self.conn.commit()
# ---------- Assistant ----------
def create_agent(self, name, model, instructions, tools=None):
aid = _uid("asst")
self.conn.execute(
"INSERT INTO agents(id,name,model,instructions,tools_json,created_at)"
" VALUES(?,?,?,?,?,?)",
(aid, name, model, instructions,
json.dumps(tools or [], ensure_ascii=False), time.time()))
self.conn.commit()
return aid
def get_agent(self, agent_id):
row = self.conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if row is None:
raise KeyError("没有这个 agent:%s" % agent_id)
agent = dict(row)
agent["tools"] = json.loads(agent.pop("tools_json"))
return agent
# ---------- Thread ----------
def create_thread(self, agent_id, title=None):
self.get_agent(agent_id) # 提前校验,避免建出孤儿 thread
tid = _uid("thread")
self.conn.execute(
"INSERT INTO threads(id,agent_id,title,created_at) VALUES(?,?,?,?)",
(tid, agent_id, title, time.time()))
self.conn.commit()
return tid
# ---------- Message ----------
def add_message(self, thread_id, role, content, extra=None):
row = self.conn.execute(
"SELECT COALESCE(MAX(seq),0)+1 AS nxt FROM messages WHERE thread_id=?",
(thread_id,)).fetchone()
mid = _uid("msg")
self.conn.execute(
"INSERT INTO messages(id,thread_id,seq,role,content,extra_json,created_at)"
" VALUES(?,?,?,?,?,?,?)",
(mid, thread_id, row["nxt"], role, content,
json.dumps(extra, ensure_ascii=False) if extra else None, time.time()))
self.conn.commit()
return mid
def history(self, thread_id, limit=40):
"""取最近 limit 条,按时间正序返回,形状直接就是 messages 数组。
limit 是必须的:托管式接口会自动截断以适应上下文窗口,
自己存就得自己截 —— 这正是自建要补回来的那部分工作量之一。
"""
rows = self.conn.execute(
"SELECT role,content,extra_json FROM messages WHERE thread_id=?"
" ORDER BY seq DESC LIMIT ?", (thread_id, limit)).fetchall()
out = []
for row in reversed(rows):
msg = {"role": row["role"]}
if row["content"] is not None:
msg["content"] = row["content"]
if row["extra_json"]:
msg.update(json.loads(row["extra_json"]))
out.append(msg)
return out
def export_thread(self, thread_id):
"""整袋导出成 JSON。托管式接口最缺的就是这个动作 ——
数据在自己库里,导出、备份、迁移随时可做。"""
return {"thread_id": thread_id, "messages": self.history(thread_id, limit=10000)}
def close(self):
self.conn.close()
if __name__ == "__main__":
store = Store(":memory:")
agent = store.create_agent("水果店财务助手", "glm-4",
"你是理财能手,统计成本与收入", tools=[])
thread = store.create_thread(agent, title="9月3日 张老板")
store.add_message(thread, "user", "卖了2斤葡萄")
store.add_message(thread, "assistant", "收到,葡萄售价4元一斤,收入8元")
store.add_message(thread, "user", "再加3斤香蕉呢")
print(json.dumps(store.export_thread(thread), indent=2, ensure_ascii=False))
store.close()
存储有了,剩下的就是把「Run」这件事写成一个函数:拼 messages → 调模型 → 有 tool_calls 就执行并回填 → 直到模型给出最终答复。
"""selfhost_agent.py —— 自建托管式助手:会话表 + Function Call 循环。
把四对象全部搬到自己家:
Assistant → agents 表里的一行
Thread → threads 表里的一行
Message → messages 表里的一行
Run → 本文件里的 run() 函数,一次调用就是一次运行
Run Step → 循环里每一轮的记录,同样落进 messages 表
底座只需要一个支持 tool calling 的 Chat Completions 接口 ——
这是目前最不可能被下线的一层,几乎每一家都兼容它。
export LLM_API_KEY='...'
export LLM_BASE_URL='https://open.bigmodel.cn/api/paas/v4/' # 换成你的
export LLM_MODEL='glm-4'
python3 selfhost_agent.py
"""
import json
from openai import OpenAI
from envkit import MissingEnv, need_env, opt_env
from selfhost_store import Store
# ---------- 工具区:这就是你的「平台能力」 ----------
PRICE = {"香蕉": 3.0, "橘子": 2.5, "苹果": 3.5, "芒果": 6.0, "葡萄": 4.0}
COST = {"香蕉": 2.0, "橘子": 1.5, "苹果": 3.0, "芒果": 5.0, "葡萄": 2.0}
def lookup_fruit(name):
if name not in PRICE:
return {"found": False, "fruit": name, "msg": "价目表里没有这一项"}
return {"found": True, "fruit": name, "cost": COST[name], "price": PRICE[name]}
REGISTRY = {"lookup_fruit": lookup_fruit}
TOOLS = [{
"type": "function",
"function": {
"name": "lookup_fruit",
"description": "按水果名查询成本价与售价,单位是元每斤",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string", "description": "水果名称"}},
"required": ["name"],
},
},
}]
def make_client():
return OpenAI(
api_key=need_env("LLM_API_KEY", "任意兼容 OpenAI 协议的模型服务密钥"),
base_url=opt_env("LLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4/"),
)
def run(store, client, thread_id, user_text, max_turns=8):
"""一次 Run:把用户消息入库 → 循环调模型与工具 → 最终答复入库。
与托管式接口的关键差别:这里每一轮的中间状态都写进 messages 表,
程序中途崩了也能接着往下跑,而不是丢一个悬空的 run_id。
"""
agent_id = store.conn.execute(
"SELECT agent_id FROM threads WHERE id=?", (thread_id,)).fetchone()["agent_id"]
agent = store.get_agent(agent_id)
store.add_message(thread_id, "user", user_text)
for turn in range(1, max_turns + 1):
# 每轮都用 instructions + 历史重新拼一份 messages
messages = [{"role": "system", "content": agent["instructions"]}]
messages += store.history(thread_id)
resp = client.chat.completions.create(
model=agent["model"], messages=messages, tools=TOOLS, tool_choice="auto")
msg = resp.choices[0].message
if not msg.tool_calls:
store.add_message(thread_id, "assistant", msg.content)
print("第 %d 轮:给出最终答复" % turn)
return msg.content
# 把模型这条带 tool_calls 的消息原样入库,回执才认得爹
store.add_message(thread_id, "assistant", msg.content, extra={
"tool_calls": [{
"id": c.id, "type": "function",
"function": {"name": c.function.name,
"arguments": c.function.arguments},
} for c in msg.tool_calls],
})
for call in msg.tool_calls:
fn = REGISTRY.get(call.function.name)
result = (fn(**json.loads(call.function.arguments)) if fn
else {"error": "未注册的函数 %s" % call.function.name})
print(" %s(%s) -> %s" % (call.function.name,
call.function.arguments, result))
store.add_message(thread_id, "tool",
json.dumps(result, ensure_ascii=False),
extra={"tool_call_id": call.id})
return "达到轮次上限仍未收敛"
def main():
store = Store()
client = make_client()
agent_id = store.create_agent(
name="水果店财务助手",
model=opt_env("LLM_MODEL", "glm-4"),
instructions="你是水果店财务助手。需要价格时必须调用工具查询,"
"不要自己编价格;最后给出成本、收入与利润,并写出计算过程。",
tools=["lookup_fruit"],
)
thread_id = store.create_thread(agent_id, title="演示会话")
print(run(store, client, thread_id,
"我卖了2斤葡萄、3.5斤香蕉、2斤苹果,总成本和总收入各是多少"))
print("---- 第二轮,验证上下文确实留在自己的库里 ----")
print(run(store, client, thread_id, "那利润是多少"))
print(json.dumps(store.export_thread(thread_id), indent=2, ensure_ascii=False))
store.close()
if __name__ == "__main__":
try:
main()
except MissingEnv as exc:
raise SystemExit(str(exc))
自己存,多写了什么,换回了什么
| 事项 | 托管式替你做 | 自建时你要做 |
|---|---|---|
| 多轮上下文 | Thread 自动带 | history() 查库拼 messages —— 大约十行 |
| 上下文截断 | 自动处理 | 自己定策略:条数上限、按 token 裁剪、超长时做摘要。history(limit=40) 只是最粗的一种 |
| 工具循环 | 状态机内部跑 | 一个 for 循环 + 轮次上限,约二十行 |
| 知识库 | 启用 retrieval 即可 | 要自己接:向量库或现成的检索服务,工作量最大的一块 |
| 代码执行 | 平台沙箱 | 要自己搭,且必须考虑隔离与资源限制 |
| 换模型厂商 | 换不了,对象模型是专有的 | 改 base_url 与模型名,业务代码不动 |
| 导出全部会话 | 只能一个个 thread 拉,接口没了就拉不了 | SELECT * FROM messages |
| 中途崩溃 | 丢一个悬空的 run_id | 每轮中间状态都已落库,能接着往下跑 |
4.5 退出演练:把托管在平台上的会话导回本地
就算决定走托管式,也必须在第一天就把撤回通道跑通一次——没演练过的备份等于没有备份。演练只有三步:平时把 thread_id 存在自己这边 → 遍历它们拉消息 → 归一成统一形状写进本地库。
"""export_hosted_thread.py —— 退出演练:把托管在平台上的会话导回自己的库。
托管式路线可以走,但必须配一条随时能撤的通道。这份脚本做的就是这件事:
遍历你记录下来的 thread_id → 拉平台上的消息列表
→ 归一成统一形状 → 写进本地 SQLite
关键在于**平时就把 thread_id 存在自己这边**。只存在平台控制台里的会话,
接口一关就再也列不出来了。
export HOSTED_API_KEY='...'
python3 export_hosted_thread.py thread_ids.txt
"""
import json
import os
import sys
import requests
from selfhost_store import Store
BASE = os.environ.get("HOSTED_BASE", "https://api.example.com")
GROUP = os.environ.get("HOSTED_GROUP_ID", "")
def _headers():
key = os.environ.get("HOSTED_API_KEY")
if not key:
raise SystemExit("环境变量 HOSTED_API_KEY 未设置(托管式平台密钥)")
return {"Authorization": "Bearer %s" % key, "Content-Type": "application/json"}
def fetch_messages(thread_id):
"""拉一个 thread 的全部消息。不同平台路径不同,改这一处即可。"""
resp = requests.get("%s/v1/threads/messages/list" % BASE,
params={"GroupId": GROUP} if GROUP else None,
headers=_headers(),
data=json.dumps({"thread_id": thread_id}), timeout=60)
resp.raise_for_status()
return resp.json()
def normalize(raw):
"""把平台返回的消息结构压成 {role, content} 列表。
各家的字段名不一样(data / messages / object 里套 content 数组),
这一层就是为了让下游只面对一种形状。
"""
items = raw.get("data") or raw.get("messages") or []
out = []
for it in items:
content = it.get("content")
if isinstance(content, list):
# 有的平台把 content 做成分块数组,取其中的文本块拼起来
parts = []
for blk in content:
if isinstance(blk, dict):
parts.append((blk.get("text") or {}).get("value")
or blk.get("text") or "")
else:
parts.append(str(blk))
content = "\n".join(p for p in parts if p)
out.append({"role": it.get("role", "assistant"),
"content": content if isinstance(content, str) else json.dumps(
content, ensure_ascii=False)})
return out
def main(id_file):
if not os.path.exists(id_file):
raise SystemExit("找不到 %s:里面一行一个 thread_id" % id_file)
store = Store()
agent_id = store.create_agent("导入的历史会话", "unknown",
"从托管平台导回的归档,仅供查阅")
total = 0
with open(id_file, encoding="utf-8") as fh:
for line in fh:
tid = line.strip()
if not tid or tid.startswith("#"):
continue
try:
msgs = normalize(fetch_messages(tid))
except Exception as exc:
print("%s 导出失败:%s" % (tid, exc))
continue
local = store.create_thread(agent_id, title="导入自 %s" % tid)
for msg in msgs:
store.add_message(local, msg["role"], msg["content"])
total += len(msgs)
print("%s → %s,%d 条" % (tid, local, len(msgs)))
print("累计导入 %d 条消息,落库路径 %s"
% (total, os.environ.get("AGENT_DB", "./agent_state.db")))
store.close()
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "thread_ids.txt")
归一层(normalize())也值得留意:各家把消息内容做成的形状不一样,有的是纯字符串,有的是分块数组。写一层归一,下游就只面对一种形状,将来再换平台时,改的仍然只有这一层。
05骨架模板:拿去改就能用
托管式通用骨架、可搬家的自建骨架,外加一个能判断「这个平台还活着吗」的体检脚本
5.1 托管式通用骨架
适用于任何提供 Assistant / Thread / Message / Run 四对象的托管式接口,不管它是拼 HTTP 还是给 SDK。设计上只做了一件事:把所有接口细节关进一个类。
| 设计 | 为什么 |
|---|---|
HostedBackend 收口 | 平台差异全在这一个类里。换平台只重写它,业务代码一行不动 |
| 业务层只有三个方法 | create_agent / open_thread / say。上层不需要知道有 Run 这回事 |
| 退避轮询 + 总超时 | 裸 sleep 要么白等、要么把服务端打满;超时保护避免线程永远挂着 |
本地留痕 self.log | 所有 id 自己也存一份。这是退出通道的入场券,不是可选项 |
| 终态分成两类 | completed 才算成功;requires_action 被明确划进「不是成功」,逼你正面处理它 |
"""hosted_agent_skeleton.py —— 托管式 Agent 接口通用骨架(改 TODO 即可)。
适用:任何提供 Assistant / Thread / Message / Run 四对象的托管式接口
(MiniMax、阿里百炼等),不管它是 HTTP 还是 SDK,形状都是这五步。
设计要点:
· 接口细节全部收进 HostedBackend 一个类,换平台只改这个类
· 业务代码只认 create_agent / open_thread / say 三个方法
· 轮询用指数退避 + 总超时,不用裸 sleep
· 会话 id 自己也留一份,平台侧出问题时能对账
export HOSTED_API_KEY='...'
python3 hosted_agent_skeleton.py
"""
import json
import os
import time
import requests
class MissingEnv(RuntimeError):
pass
def need_env(name, what=""):
val = os.environ.get(name)
if not val:
raise MissingEnv("环境变量 %s 未设置(%s)" % (name, what))
return val.strip()
class HostedBackend:
"""把一家平台的四对象接口包成统一形状。换平台只重写这个类。"""
# TODO 1:换成目标平台的 base url 与鉴权方式
BASE = os.environ.get("HOSTED_BASE", "https://api.example.com")
def __init__(self):
self.key = need_env("HOSTED_API_KEY", "托管式平台密钥")
# TODO 2:有的平台还要 GroupId / workspace 之类的租户参数,在这里补
self.extra = {k: v for k, v in
(("GroupId", os.environ.get("HOSTED_GROUP_ID")),) if v}
self.session = requests.Session()
def _headers(self):
return {"Authorization": "Bearer %s" % self.key,
"Content-Type": "application/json"}
def _post(self, path, body=None):
resp = self.session.post("%s/%s" % (self.BASE, path),
params=self.extra, headers=self._headers(),
data=json.dumps(body or {}), timeout=60)
if resp.status_code != 200:
raise RuntimeError("%s 返回 HTTP %s:%s"
% (path, resp.status_code, resp.text[:300]))
return resp.json()
# ---- 四对象,每个平台都有对应接口,只是路径不同 ----
def create_agent(self, name, model, instructions, tools=None):
# TODO 3:换成该平台的创建助手接口与字段名
data = self._post("v1/assistants/create", {
"name": name, "model": model,
"instructions": instructions, "tools": tools or [],
})
return data["id"]
def create_thread(self):
# TODO 4:换成该平台的创建线程接口
return self._post("v1/threads/create")["id"]
def add_message(self, thread_id, content, role="user"):
# TODO 5:换成该平台的添加消息接口
return self._post("v1/threads/messages/add",
{"thread_id": thread_id, "role": role, "content": content})
def create_run(self, thread_id, agent_id):
# TODO 6:换成该平台的创建运行接口
return self._post("v1/threads/run/create",
{"thread_id": thread_id, "assistant_id": agent_id})["id"]
def run_status(self, thread_id, run_id):
# TODO 7:换成该平台的查询运行状态接口
return self._post("v1/threads/run/retrieve",
{"thread_id": thread_id, "run_id": run_id}).get("status", "")
def list_messages(self, thread_id):
# TODO 8:换成该平台的消息列表接口
return self._post("v1/threads/messages/list", {"thread_id": thread_id})
TERMINAL_OK = {"completed", "succeeded"}
TERMINAL_BAD = {"failed", "cancelled", "expired", "requires_action"}
class HostedAgent:
"""业务层只用这三个方法,接口换代时这一层不用改。"""
def __init__(self, backend, name, model, instructions, tools=None):
self.be = backend
self.agent_id = backend.create_agent(name, model, instructions, tools)
# 本地留痕:托管式对象的 id 是唯一能对账的东西,务必自己也存一份
self.log = {"agent_id": self.agent_id, "threads": []}
def open_thread(self):
tid = self.be.create_thread()
self.log["threads"].append({"thread_id": tid, "runs": []})
return tid
def say(self, thread_id, text, timeout=180):
self.be.add_message(thread_id, text)
run_id = self.be.create_run(thread_id, self.agent_id)
self.log["threads"][-1]["runs"].append(run_id)
started, delay = time.time(), 0.5
while True:
status = self.be.run_status(thread_id, run_id)
if status in TERMINAL_OK:
return self.be.list_messages(thread_id)
if status in TERMINAL_BAD:
raise RuntimeError("Run 以 %s 结束" % status)
if time.time() - started > timeout:
raise TimeoutError("等待 %ss 后仍为 %s" % (timeout, status))
time.sleep(delay)
delay = min(8.0, delay * 1.8) # 退避,别把服务端打满
if __name__ == "__main__":
try:
agent = HostedAgent(
HostedBackend(),
name="TODO 9:助手名字",
model="TODO 10:平台上的模型名",
instructions="TODO 11:岗位说明书,写清角色、边界、输出格式",
tools=[], # TODO 12:平台内置工具类型
)
thread = agent.open_thread()
print(json.dumps(agent.say(thread, "TODO 13:第一句话"),
indent=2, ensure_ascii=False))
print("本地留痕:", json.dumps(agent.log, ensure_ascii=False))
except MissingEnv as exc:
raise SystemExit(str(exc))
TERMINAL_BAD 里包含 requires_action,也就是说:模板遇到「需要你执行函数」会直接报错退出。这是故意的——用到函数调用时,你必须自己在这里补上「执行本地函数并提交工具输出,然后继续等」的分支,而不是让它悄悄地被当成失败。参照 run_state_machine.py 里的 drive() 写法。
5.2 可搬家的自建骨架
这份模板的全部价值在一个词:可替换。一个抽象层 Backend,两个实现——会话存自己库里的 LocalBackend,会话交给平台的 HostedBackend。业务层只调 Agent.say(),换后端是一行配置的事。
"""portable_agent_skeleton.py —— 不会被下线的那条退路:自建骨架。
一个抽象层 + 两个后端:
HostedBackend 会话交给平台保管(好处:省事;代价:跟着平台生死)
LocalBackend 会话存在自己的库里(好处:可导出可迁移;代价:多写一点)
业务代码只调 Agent.say(),换后端一行配置的事。
迁移那天真正要改的,只有后端类里的几十行。
export AGENT_BACKEND=local # local / hosted
export LLM_API_KEY='...'
export LLM_BASE_URL='...'
export LLM_MODEL='...'
python3 portable_agent_skeleton.py
"""
import json
import os
import sqlite3
import time
import uuid
class MissingEnv(RuntimeError):
pass
def need_env(name, what=""):
val = os.environ.get(name)
if not val:
raise MissingEnv("环境变量 %s 未设置(%s)" % (name, what))
return val.strip()
# ------------------------------------------------------------------ 抽象层
class Backend:
"""一个会话后端要能做三件事:开会话、追加消息、取历史。"""
def open_thread(self):
raise NotImplementedError
def append(self, thread_id, message):
raise NotImplementedError
def history(self, thread_id, limit=40):
raise NotImplementedError
# ------------------------------------------------------------------ 本地后端
class LocalBackend(Backend):
"""会话落在自己的库里。表结构极简,换 MySQL 只改建表语句与占位符。"""
def __init__(self, path=None):
self.conn = sqlite3.connect(path or os.environ.get("AGENT_DB", "./agent.db"))
self.conn.execute("""CREATE TABLE IF NOT EXISTS msg(
id TEXT PRIMARY KEY, thread_id TEXT, seq INTEGER,
payload TEXT, created_at REAL)""")
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_msg ON msg(thread_id, seq)")
self.conn.commit()
def open_thread(self):
return "thread_%s" % uuid.uuid4().hex[:24]
def append(self, thread_id, message):
seq = self.conn.execute(
"SELECT COALESCE(MAX(seq),0)+1 FROM msg WHERE thread_id=?",
(thread_id,)).fetchone()[0]
self.conn.execute(
"INSERT INTO msg(id,thread_id,seq,payload,created_at) VALUES(?,?,?,?,?)",
("msg_%s" % uuid.uuid4().hex[:24], thread_id, seq,
json.dumps(message, ensure_ascii=False), time.time()))
self.conn.commit()
def history(self, thread_id, limit=40):
rows = self.conn.execute(
"SELECT payload FROM msg WHERE thread_id=? ORDER BY seq DESC LIMIT ?",
(thread_id, limit)).fetchall()
return [json.loads(r[0]) for r in reversed(rows)]
def export(self, thread_id):
"""可导出,是自建相对托管的核心优势,别省掉这个方法。"""
return self.history(thread_id, limit=10 ** 6)
# ------------------------------------------------------------------ 托管后端
class HostedBackend(Backend):
"""会话交给平台。这里只留接线位置,具体接口按平台文档填。"""
def __init__(self, client):
self.client = client # TODO 1:传入平台 SDK 或 requests session
def open_thread(self):
# TODO 2:调平台的建会话接口,返回它的 id
raise NotImplementedError("接上平台的创建会话接口")
def append(self, thread_id, message):
# TODO 3:调平台的添加消息接口
raise NotImplementedError("接上平台的添加消息接口")
def history(self, thread_id, limit=40):
# TODO 4:调平台的消息列表接口,转成统一形状后返回
raise NotImplementedError("接上平台的消息列表接口")
# ------------------------------------------------------------------ 业务层
class Agent:
"""岗位说明书 + 工具表 + 一次运行的循环。与后端解耦。"""
def __init__(self, backend, model, instructions, tools=None, registry=None):
self.be = backend
self.model = model
self.instructions = instructions
self.tools = tools or []
self.registry = registry or {}
self._client = None
def client(self):
if self._client is None:
from openai import OpenAI # 延迟导入,纯本地自检时不需要它
self._client = OpenAI(
api_key=need_env("LLM_API_KEY", "模型服务密钥"),
base_url=os.environ.get("LLM_BASE_URL") or None)
return self._client
def say(self, thread_id, text, max_turns=8):
"""一次运行:入库 → 调模型 → 有工具就执行并回填 → 直到给出答复。"""
self.be.append(thread_id, {"role": "user", "content": text})
for _turn in range(max_turns):
messages = [{"role": "system", "content": self.instructions}]
messages += self.be.history(thread_id)
resp = self.client().chat.completions.create(
model=self.model, messages=messages,
tools=self.tools or None,
tool_choice="auto" if self.tools else None)
msg = resp.choices[0].message
if not msg.tool_calls:
self.be.append(thread_id,
{"role": "assistant", "content": msg.content})
return msg.content
self.be.append(thread_id, {
"role": "assistant", "content": msg.content,
"tool_calls": [{
"id": c.id, "type": "function",
"function": {"name": c.function.name,
"arguments": c.function.arguments}}
for c in msg.tool_calls]})
for call in msg.tool_calls:
fn = self.registry.get(call.function.name)
out = (fn(**json.loads(call.function.arguments)) if fn
else {"error": "未注册函数 %s" % call.function.name})
self.be.append(thread_id, {
"role": "tool", "tool_call_id": call.id,
"content": json.dumps(out, ensure_ascii=False)})
return "达到轮次上限"
def make_backend():
kind = os.environ.get("AGENT_BACKEND", "local")
if kind == "local":
return LocalBackend()
# TODO 5:托管分支里构造平台 client 后交给 HostedBackend
raise MissingEnv("AGENT_BACKEND=hosted 需要先接好平台 client")
if __name__ == "__main__":
# TODO 6:换成你自己的函数与描述
def ping(word):
return {"echo": word}
agent = Agent(
backend=make_backend(),
model=os.environ.get("LLM_MODEL", "glm-4"),
instructions="TODO 7:岗位说明书",
tools=[{"type": "function", "function": {
"name": "ping", "description": "回声测试",
"parameters": {"type": "object",
"properties": {"word": {"type": "string"}},
"required": ["word"]}}}],
registry={"ping": ping},
)
tid = agent.be.open_thread()
try:
print(agent.say(tid, "TODO 8:第一句话"))
except MissingEnv as exc:
print("(未配置模型密钥,跳过实际调用)", exc)
print("本地会话内容:", json.dumps(agent.be.history(tid), ensure_ascii=False))
| 如果将来发生 | 要改的地方 |
|---|---|
| 模型厂商涨价 / 下线 | LLM_BASE_URL 与 LLM_MODEL 两个环境变量 |
| 托管式接口停服 | AGENT_BACKEND 从 hosted 改成 local,历史用 4.5 的脚本导回来 |
| SQLite 撑不住了 | LocalBackend 里的建表语句与占位符,其余不动 |
| 要加新工具 | registry 加一个函数,tools 加一段描述 |
| 要换成新接口协议 | Agent.say() 里调模型那几行 |
from openai import OpenAI 写成延迟导入
因为纯本地自检(只验证会话表读写)时不该被一个第三方库卡住。这类「能力按需加载」的小习惯,在工具链断掉的时候能省下很多时间。
5.3 平台体检脚本:把「还活着吗」变成一条命令
托管式路线最大的风险是平台把接口关了。与其读别人文章里的快照数字,不如自己跑一条命令。对开源平台,三个字段就够判断:
| 字段 | 怎么取 | 怎么读 |
|---|---|---|
| 最后提交时间 | pushed_at | 最重要的一个。长期不动的项目不要押上业务;活跃项目通常以周甚至天计 |
| 关注度 | stargazers_count | 只作参考。星多不等于维护好,更不等于协议适合商用 |
| 许可证 | license.spdx_id | 返回 NOASSERTION 说明不是标准协议,必须打开 LICENSE 原文逐条读 |
"""platform_health.py —— 把「这个平台还活着吗」做成一条可执行的命令。
托管式路线最大的风险是「平台把接口关了」。挑平台之前、每隔一段时间,
都该自己复核一次,而不是信任任何一篇文章里的快照数字。
对开源平台,GitHub 的三个字段就够判断:
pushed_at 最后一次提交时间 —— 长期不动的项目别押上业务
stargazers_count 关注度,只作参考
license.spdx_id 许可证;返回 NOASSERTION 说明不是标准协议,
必须自己打开 LICENSE 原文逐条读,尤其是商用与多租户条款
对闭源托管接口,看它的官方文档里有没有 deprecations 页面,
以及有没有写明「下线时间与迁移指南」—— 有,说明这家把废弃当正事办。
python3 platform_health.py langgenius/dify coze-dev/coze-studio
"""
import json
import sys
import urllib.request
API = "https://api.github.com/repos/%s"
def fetch(repo, token=None):
req = urllib.request.Request(
API % repo,
headers={"Accept": "application/vnd.github+json",
"User-Agent": "platform-health-check"})
if token:
# 匿名请求有频率限制;需要时用环境变量里的 token 提高配额,
# 绝不把 token 写进源码
req.add_header("Authorization", "Bearer %s" % token)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def summarize(repo, token=None):
data = fetch(repo, token)
lic = (data.get("license") or {}).get("spdx_id") or "无"
return {
"repo": data.get("full_name"),
"stars": data.get("stargazers_count"),
"pushed_at": data.get("pushed_at"),
"license": lic,
"license_warning": lic in ("NOASSERTION", "无"),
}
def main(repos):
import os
token = os.environ.get("GITHUB_TOKEN")
for repo in repos:
try:
info = summarize(repo, token)
except Exception as exc: # 网络不通也要给出明确提示
print("%s 查询失败:%s" % (repo, exc))
continue
print("%-26s star=%-8s 最后提交=%s 许可证=%s"
% (info["repo"], info["stars"], info["pushed_at"], info["license"]))
if info["license_warning"]:
print(" ⚠ 许可证不是标准 SPDX 协议,去仓库里读 LICENSE 原文,"
"重点看商用范围、多租户限制与 LOGO/版权信息条款")
if __name__ == "__main__":
args = sys.argv[1:] or ["langgenius/dify", "coze-dev/coze-studio"]
main(args)
闭源托管接口没有仓库可查,换三个问题:官方文档里有没有一个专门的弃用页面?历史上下线接口时给了多长的过渡期?有没有配套的迁移指南和对象映射表?三个都有,说明这家把废弃当正事办——托管式 Agent 接口这次换代,恰好就是一个正面样本:提前一年公告,到期执行,附带逐对象的映射说明。
06易错点汇总
按「认知 / 四对象 / 轮询 / 迁移 / 自建 / 选型」六类归并,踩过一次就别再踩
⚠️ 一、认知层面
- 照着旧教程敲 OpenAI 的
assistants.create。这套接口已于 2026 年 8 月 26 日下线,调不通不是网络问题也不是密钥问题。Azure 上的同名接口同日退役。正确动作是照 2.6 的映射表改写成 Responses + Conversations。 - 把「已下线」理解成「这套概念作废了」。四对象是托管式 Agent 的通用抽象,国产平台今天仍在用,接口路径几乎逐字对应。作废的是某一家的实现,不是这套心智模型。
- 以为 GPTs 和托管式接口是两种技术。它们是同一套东西的两张脸:都是「指令 + 知识库 + 工具」托管在平台。区别在创建方式(表单 vs 代码)、界面归属(平台的 vs 你的)、分发方式(内置分享 vs 自己搞定)。
- 以为托管式接口里模型会自己执行函数。不会。Function calling 在托管式接口里只是被包进了状态机:运行停在
requires_action等你执行完再提交结果。执行的永远是你的代码。
⚠️ 二、四对象用错
- 每次请求都新建一个 Assistant。岗位说明书是一次性动作,助手对象会在平台上越堆越多,还会让「线上到底用的哪一版指令」彻底查不清。建一次,把 id 存进配置。
- 一个 Thread 混装多个客户。档案袋是按客户/会话分的。混装的后果是上下文互相污染,A 客户的问题里冒出 B 客户的数据——既是质量问题,也是隐私事故。
- 加完 Message 就去读回复。写信不惊动任何人。必须再喊一声 Run,否则袋子里永远只有你自己塞进去的那封。
- 以为 Run 的返回值里有答案。Run 是异步的,创建时只返回 id 和初始状态。答复是被追加进 Thread 的一条新消息,要回袋子里取。
- thread_id 只存在平台那边。没记在自己库里的会话,接口一关就再也列不出来。这是 4.5 那条退出通道的前提,缺了它备份计划整个作废。
- 把 Run Step 当成必需品。它是排查工具,不是主流程;有的平台压根不单独暴露。主流程只依赖四对象就够。
⚠️ 三、轮询与错误处理
- 把
requires_action当终态。最典型的一个坑。现象是「状态查到了,消息列表却永远是空的」,程序悄悄什么都没干。它的含义是「该你执行本地函数了」,执行完提交回去,运行才继续。 - 裸
time.sleep(2)死循环。任务两秒内结束时白等;任务跑一分钟时几十次请求全打在服务端上;平台出故障时这个循环永远不会退出。退避 + 上限 + 总超时,三件都要有。 - 只看 HTTP 状态码。这类接口常见 HTTP 200 但业务码非 0。漏判的后果是带着空 id 一路往下跑,最后在毫不相干的地方报错。
- SDK 不抛异常却没检查返回对象。某些 SDK 把状态码挂在返回对象上、失败时不中断程序。每一步都要显式
verify(),并把步骤名一起打出来。 - 什么错都重试。参数错、鉴权错重试一百次结果一样,只会白烧配额。只重试瞬时故障(网关 502、连接重置)。
- 刚建完挂了文件的助手就立刻 Run。平台要做向量化与存储,可能还没就绪,表现为检索不到内容。稳妥做法是先查助手状态确认就绪,而不是盲目 sleep 一个拍脑袋的秒数。
- 上传文件时手写
Content-Type: application/json。上传走 multipart,手动设置会让 boundary 补不上,服务端直接解析失败。
⚠️ 四、迁移时
- 想用接口创建 Prompts。Prompts 只能在控制台里创建,接口建不了。所有「程序启动时自动建助手」的代码都要拆掉,改成按 id 引用,或把指令随请求传。
- 把轮询那一整段照搬过去。Responses 是同步返回的,
resp.output就是结果。留着旧轮询代码不会报错,只会让人以为还有个 run 状态机要等。 - 迁完忘了自己写工具循环。旧接口里循环是平台的,新接口里是你的。不写循环的现象是:模型返回了
function_call,程序当成最终回复打出来,用户看到一段 JSON。 - 读 conversation 历史时假设全是 message。里面装的是 item:消息、工具调用、工具输出都在内。遍历时先看
type再决定怎么渲染。 - 工具循环没有轮次上限。工具描述含糊时模型可能反复调同一个函数。
for turn in range(8)这种兜底必须有。 - 回填时
call_id对不上。多个并行函数调用时,每个function_call_output都要配对应那一次调用的 id,复制粘贴第一个会直接报错。 - 迁移时顺手换了模型。两件事一起改,出问题时分不清是接口改错了还是模型换坏了。先平移接口、验证行为一致,再考虑换模型。
⚠️ 五、自建时
- 不做上下文截断。托管式接口自动处理内容截断,自己存就得自己截。不截的现象是对话到第几十轮突然开始报超长错误。条数上限是最粗的一种,按 token 裁剪或超长做摘要更稳。
- 忘了给 messages 表建
(thread_id, seq)索引。取历史是最高频的查询,没索引时数据一多整个应用都慢下来。 - 回填时丢了带
tool_calls的那条 assistant 消息。工具回执靠tool_call_id认领自己属于哪次调用;上一条 assistant 没入库,回执就成了孤儿,接口直接报错。顺序也不能颠倒:先 assistant,后 tool。 - 把密钥写进源码。一律
os.environ.get(...),缺失时抛一句人能看懂的话。打日志时把密钥打码再打。 - 用
os.environ[...]直接取值。抛出来的是一个光秃秃的KeyError,新同事看不懂该去设哪个变量。自己包一层,把设置方法写进报错信息里。 - 工具函数抛异常打断循环。「查无此项」是模型需要知道的事实,不是程序故障。正常返回一个说明结果,让模型自己纠正;真异常才抛。
- 会话表建好了却从没演练过导出。没演练过的备份等于没有备份。至少跑一次完整的导出—导入,确认拿到的内容能读。
⚠️ 六、选型与合规
- 把「基于 Apache-2.0」当成「就是 Apache-2.0」。存在这样的情况:主体沿用 Apache-2.0 但附加了商用限制——未经书面授权不得用其源码运营多租户环境,使用其前端时不得移除或修改控制台与应用中的 LOGO 和版权信息。读原文,不看摘要;这直接决定能不能拿它对外卖服务。
- 看 star 数选平台。星多不等于维护好。最后提交时间比星数有用得多,许可证比两者都重要。
- 相信文章里的快照数字。star、提交时间、定价、额度全都会变。把体检脚本写进例行检查清单,自己跑。
- 把托管式当成永久方案。一个由头部厂商发布、写进无数教程的接口,从发布到下线不到三年。凡是把状态交出去的设计,第一天就要配好撤回通道。
- 为了「不被锁定」而拒绝一切平台能力。另一个极端。合理做法是分层:会话状态自己存,算力和检索可以买;把不可替代的部分控制在一个能被重写的模块里。
07自测题
点击题目展开答案;能把这 10 题说清楚,这一讲就通了
GPTs 和托管式 Agent 接口的联系与区别各是什么?
联系:都基于同一批大语言模型,都通过指令、知识库、工具来做定制,都为用户提供 AI 助手体验,只是面向的对象和使用场景不同。
区别:GPTs 用无代码方式创建,托管式接口需要写代码集成;GPTs 的用户直接使用平台自带的聊天界面,托管式接口需要开发者构建自定义界面;GPTs 有内置的分享功能,托管式接口没有,分发要自己解决。
共同点还有一条常被忽略:两者的会话状态都在平台手里,区别只是你看不看得见那些 id。
今天还能调 OpenAI 的 Assistants API 吗?现在该用什么?既然已经下线,为什么还要把四对象讲透?
不能。它于 2026 年 8 月 26 日正式 sunset,已不再可用(弃用通知提前一年发出)。Azure OpenAI 上的同名接口同日退役。
继任方案是 Responses API(2025 年 3 月发布)配合 Conversations API。Responses 还带来了托管式四对象没有的能力:deep research、MCP、computer use。
仍要讲透四对象,有三个理由:① 四对象是托管式 Agent 的通用抽象,国产平台今天仍在用,接口路径几乎逐字对应,学会了换平台三分钟上手;② 新接口是它的演化而不是推倒重来,理解旧对象后迁移就成了查表替换;③ 一个头部厂商的接口不到三年就下线,怎么设计才不被一次下线打死,比任何具体接口都值钱。
Assistant、Thread、Message、Run、Run Step 各是什么?谁从属于谁?
Assistant:特定目的的助手,使用平台模型并调用工具执行任务 —— 岗位说明书,与具体会话无关,建一次可被无数 Thread 复用。
Thread:助手与用户之间的对话会话,存储消息并自动处理内容截断以适应上下文限制 —— 一个客户的档案袋。
Message:由助手或用户创建的消息,以列表形式存储在线程上 —— 袋子里的一封信。
Run:在线程上调用助手的一个实例;助手用自身配置和线程上的消息执行任务,并向线程追加消息 —— 喊一声「照着档案办」。
Run Step:助手在运行过程中所采取的详细步骤列表,用来内省它如何得出最终结果 —— 工作流水。
从属关系:一个 Assistant 可服务多个 Thread;一个 Thread 装多条 Message,也可以先后跑多次 Run;一次 Run 内部有多个 Run Step。
往 Thread 里加了 Message 之后,为什么读不到模型的回复?
因为添加消息只是存档,不会触发模型。必须再创建一次 Run 来激活助手;Run 是异步的,创建时只返回 id,要轮询到 completed,然后回到 Thread 上列出消息取最新那条 assistant 消息 —— 回复是被追加进 Thread 的,不是 Run 的返回值。
Run 的状态查到 requires_action 了,接下来该做什么?
它不是终态。含义是助手要调用一个你注册的函数,而函数在你的机器上。正确动作是:取出它要求调用的函数名与参数 → 在自己的代码里执行 → 把结果作为工具输出提交回去 → 运行回到 in_progress,继续轮询。
把它当终态处理的典型现象是「状态查到了,消息列表却永远是空的」。
为什么轮询不能写 while True: sleep(2)?正确写法包含哪三件东西?
两个毛病:任务两秒内结束时白等;任务跑一分钟时几十次请求全打在服务端上。还有第三个更危险的 —— 平台故障时这个循环永远不会退出。
正确写法三件套:指数退避(间隔逐渐变长,带抖动避免惊群)、间隔上限(别退避到几分钟才查一次)、总超时(超时抛错,不要无限等)。另外只对瞬时故障重试,参数错和鉴权错重试无意义。
四个旧对象分别被谁接了班?哪一格改动最大?
Assistants → Prompts(配置可版本化、可回滚,但只能在控制台创建,不能用接口建)
Threads → Conversations(从只存 message 变成存 item)
Runs → Responses(送进 input items、拿回 output items,工具调用循环改为显式自己管)
Run steps → Items(泛化成统一对象)
改动最大的是 Runs → Responses 这一格:循环从平台手里拿回来了,轮询那一整段要删掉,等 requires_action 的分支要重写成自己的 while。
迁移后,「模型要求调函数」这件事在代码里长什么样?
旧:Run 进入 requires_action,你调「提交工具输出」接口回传,运行自动继续。
新:output 里出现 type 为 function_call 的 item;你执行函数后,把 {"type": "function_call_output", "call_id": ..., "output": ...} 作为新的 input item 再发一次请求;循环终止条件是 output 里不再出现 function_call。
两件事不变:执行函数的永远是你的代码;回填必须靠 id 配对。新增的一件事:自己写轮次上限兜底,防止模型反复调同一个函数造成死循环。
把会话状态托管出去,具体丢掉了什么?自己存又要补写哪些东西?
丢掉的:完整的往来档案、助手的工作流水、任何本地备份。你手上只剩 assistant_id 和 thread_id 两个字符串;接口下线时历史会话列不出来、助手配置要重建、转发层几乎要重写。
自己存要补:多轮上下文拼装(约十行)、上下文截断策略(条数上限 / 按 token 裁剪 / 超长摘要)、工具调用循环(约二十行)、知识库要自己接、代码沙箱要自己搭。
换回来的:换模型厂商只改 base_url 与模型名、全部会话一条 SQL 就能导出、程序中途崩溃能接着跑、没有人能把它下线。
怎么判断一个平台还值不值得押上业务?至少说出三个可执行的动作。
开源平台查三个字段:最后提交时间(最重要,长期不动的别押业务)、关注度(仅作参考)、许可证(返回 NOASSERTION 说明不是标准协议,必须读 LICENSE 原文,重点看商用范围、多租户限制、LOGO 与版权信息条款)。
闭源托管接口换三个问题:官方文档里有没有专门的弃用页面?历史上下线接口给过多长过渡期?有没有配套迁移指南与对象映射表?
还有一个动作与平台无关但最管用:先把退出通道跑通一次 —— thread_id 存在自己库里,写好导出脚本,真的导一次并确认内容能读。没演练过的备份等于没有备份。
词术语表
| 术语 | 含义 |
|---|---|
| GPTs | 无需写代码即可创建的定制化 AI 助手,靠平台内置的分享功能分发 |
| Assistant | 托管在平台上的助手对象:模型 + instructions + tools,建一次可被多个会话复用 |
| Thread | 助手与用户之间的对话会话,存储消息并自动处理内容截断以适应上下文限制 |
| Message | 由助手或用户创建的消息,以列表形式存储在线程上;写入不触发模型 |
| Run | 在某个 Thread 上调用某个 Assistant 的一次实例;异步执行,会向线程追加消息 |
| Run Step | 一次 Run 内部的详细步骤列表,用来内省助手如何得出最终结果 |
requires_action | Run 的中间状态,表示需要开发者执行本地函数并提交工具输出;不是终态 |
| Code Interpreter | 托管式工具之一:在平台沙箱里真的执行一段代码 |
| Retrieval | 托管式工具之一:把挂载的文件切片建索引,回答时自动检索 |
| Function calling | 模型输出要调用的函数名与参数,执行仍由开发者的代码完成 |
| Responses API | 接替托管式助手接口的调用方式:送进 input items,拿回 output items,同步返回 |
| Conversations API | 接替 Thread 的会话容器,存的是 item 而不只是 message |
| Prompts | 接替 Assistant 的配置对象,可版本化、可回滚;只能在控制台创建 |
| Item | Responses 体系里的统一对象:消息、工具调用、工具输出都是 item |
function_call_output | 新接口里回填工具执行结果的 input item 类型,靠 call_id 与调用配对 |
| 指数退避 | 轮询或重试时让间隔逐渐变长并加抖动,配合上限与总超时使用 |
| 供应商锁定 | 把配置、会话、数据托管在某一家平台后,难以低成本迁走的状态 |
| 退出通道 | 事先备好并演练过的迁出方案:自己记 id、定期导出、后端可替换 |