GPTs 与 Assistant API:托管式 Agent 的现状与迁移

把助理连同他的档案柜一起外包给中介公司——省心、快、少写代码,代价是中介关门那天,你的上下文跟着一起消失。

30″30 秒看懂托管式 Agent

你要给自家水果店配一位财务助理。第一种办法是自己招人、自己给他一间办公室、自己买一个档案柜;第二种办法是找中介公司外包——人是中介的人,档案柜也摆在中介的楼里,你手上只留一张纸条,上面写着两个编号:助理编号档案袋编号

托管式 Agent 走的就是第二条路。你在平台上签一份岗位说明书(用哪个模型、什么人设、能用哪些工具),平台给你一个 assistant_id;每来一位客户,你让中介开一个档案袋,拿到 thread_id;客户说的每句话,你写成信件塞进袋子;要办事的时候,你喊一声「照着档案办」,助理就翻档案、查资料、算账、把回信也放进同一个袋子。整个过程你没看见助理,也没摸到档案——你只在收发编号

把私人助理连同他的档案柜,一起外包给中介公司 你只留下一张门牌号:助理编号、档案袋编号。人和柜子都在对方的楼里 你的公司(你的代码) 发起委托的人 只知道两个编号: assistant_id / thread_id 你手上剩下什么 · 一次提问的文本 · 一次回信的文本 · 若干个字符串编号 你手上没有的东西 · 完整的往来档案 · 助理的工作流水 · 任何一份本地备份 中介公司(平台的服务器) 签了岗位说明书的助理 Assistant 模型 / 指令 / 可用工具 建一次,反复使用 一个客户一个档案袋 Thread 按顺序装着往来信件 自动截断以适配上下文 袋子里的一张张信件 Message user 写的问、assistant 写的答,都存在这里,不在你机器上 「照着档案办」一次 Run + 流水记录 Run Step 助理翻档案、查资料、算数、写回信——每一步都记在中介的台账上 你看不到过程,只能查状态:排队中 / 进行中 / 已完成 委托 回信 中介关门那天 助理不再上班 建助手的接口返 404 档案袋一并消失 再也列不出历史消息 编号还在,对不上人 提前做的事才救得回 · 编号自己也存一份 · 定期把档案导回本地 · 岗位说明书写在自己 的代码仓库里 · 会话后端做成可替换 档案袋存在中介手里——中介关门那天,你的上下文跟着一起消失
图① 30 秒看懂:把助理连同档案柜一起外包给中介公司
比喻里的角色对应的技术概念它到底是什么
签了岗位说明书的助理Assistant模型 + instructions + tools 打包成的服务端对象,建一次可被无数会话复用
中介替你保管的档案袋Thread一位客户的会话容器,本身不含任何配置,只是一个有序的信件盒
袋子里的一张张信件Messageuser 说的、assistant 写的,按顺序存着;写进去不会触发模型
喊一声「照着档案办」Run在某个 Thread 上用某个 Assistant 跑一次;异步,立刻返回一个 id,结果得回头去取
助理办事的流水记录Run Step这次运行内部调了什么工具、写了什么消息,用来事后排查
你手上的那张纸条两个字符串 id你在本地唯一拥有的东西;档案的正本从来不在你这儿

零代码那一半(GPTs 这一类产品)连纸条都不用你拿:岗位说明书在网页表单里填,档案袋由平台的聊天界面自动开,分发靠一条平台内的分享链接。它和写代码那一半是同一套东西的两张脸——底下都是「配置 + 会话 + 运行」三件事托管在别人家。

⛔ 整讲只有一条铁律 档案袋存在中介手里——中介关门那天,你的上下文跟着一起消失。这不是危言耸听:OpenAI 的 Assistants API 已经在 2026 年 8 月 26 日正式下线,现在调不通了,Azure OpenAI 上的同名接口同日退役。四对象这套心智模型依然值得吃透(国产托管式平台仍在用它),但任何把状态交出去的设计,都要在第一天就配好一条撤回来的通道
这一讲你会拿到什么 ① 四对象心智模型讲透,换任何一家托管式平台都能三分钟上手;② OpenAI 侧下线后的对象映射表与可运行的迁移代码;③ 国产托管式接口的完整跑通流程;④ 一套不会被谁下线的自建方案:Function Call + 三张会话表;⑤ 两份骨架模板与一个平台体检脚本。

01概念:托管式 Agent 的两张脸

零代码的 GPTs、写代码的托管式接口、二者的联系与区别,以及 OpenAI 侧今天的真实状况

1.1 GPTs:零代码的那一半

2023 年 11 月,OpenAI 为 ChatGPT 推出了 GPTs:允许用户无需写代码,就能根据特定需求创建「属于自己的 ChatGPT 版本」,也就是基于 ChatGPT 做出一个定制化的个人 AI 助手。到 2024 年 1 月,个性化 GPT 的数量已经以百万计。

它的做法一句话说得清:把原本要写在代码里的三件事,搬进一张网页表单

01人设与指令

在编排页面填一段角色设定:你是谁、擅长什么、遇到什么不回答、输出用什么格式。这段文字就是后面代码里的 instructions

02知识

上传若干文档,平台替你切片、向量化、建索引。提问时自动检索,检索到的片段拼进上下文——也就是代码里的 Retrieval 工具。

03能力

勾选内置工具(联网搜索、画图、代码执行),或挂一个外部接口。对应代码里的 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 侧的现状:这套接口已经下线了

⛔ 先把事实摆正 OpenAI 的 Assistants API 已于 2026 年 8 月 26 日正式 sunset,现在不再可用。弃用通知在一年前(2025 年 8 月 26 日)发出,一年后如期执行。Azure OpenAI 上的 Assistants API 同日退役。继任者是 Responses API(2025 年 3 月发布)配合 Conversations API

所以今天读到任何一段 client.beta.assistants.create(...) 的 OpenAI 示例代码,结论都是一样的:照着敲会失败,不是网络问题,也不是密钥问题。正确的动作是照 2.6 的映射表改写。

但这不意味着这一讲可以跳过。三个理由:

01抽象还活着

「配置 / 会话 / 运行」这三件事托管在服务端,是托管式 Agent 的通用抽象。国产平台(MiniMax、阿里百炼等)今天仍在用完全一样的四对象,接口名几乎逐字对应。

02新接口是它的演化

Responses 不是推倒重来,而是把四对象拆细、泛化。理解了旧的四对象,新接口的每个概念都能找到出处,迁移就成了查表替换。

03这是一次可复用的教训

一个由头部厂商发布、写进无数教程的接口,从发布到下线不到三年。怎么设计才能不被一次下线打死,比任何一个具体接口都值钱。

后面的顺序就按这三条走:先把四对象讲透(第 02 节),再讲它为什么被换掉、换成了什么(2.5、2.6),然后用真实可跑的代码走三条路——托管式、迁移后、自建(第 03、04 节)。

02原理:四个对象、一台状态机、一次换代

从属关系与时间轴、Run 的状态分支、这套抽象解决了什么问题、为什么又被换掉、新旧怎么对应

2.1 四对象心智模型

托管式接口的全部复杂度,就在这四个(严格说是五个)对象上。先把官方定义逐条读一遍,再用比喻复述:

对象定义
Assistant
助手
一个特定目的的人工智能助手,它使用平台的模型并调用工具来执行任务。开发者可以构建 Assistant 来响应用户的特定需求。
Thread
线程
代表助手和用户之间的对话会话。线程存储消息,并自动处理内容截断,以适应模型的上下文限制。
Message
消息
由助手或用户创建的消息。消息可以包括文本、图像和其他文件类型,并以列表形式存储在线程上
Run
运行
在线程上调用助手的一个实例。助手使用其配置和线程上的消息,通过调用模型和工具来执行任务。作为运行的一部分,助手会向线程追加消息
Run Step
步骤
助手在运行过程中所采取的详细步骤列表。助手可以在运行过程中调用工具或创建消息。检查运行步骤可以让你内省助手是如何得出最终结果的。
四个对象:谁从属于谁,谁按什么顺序出现 左边是从属关系(一对多),右边是一次问答的时间轴 从属关系 Assistant 岗位说明书 模型 + instructions + tools + 挂载的文件;与任何一次对话无关 一个助手可被无数会话复用 Thread 一个客户的档案袋 自身不含任何配置,只是一个有序容器;平台替它做上下文截断 一个档案袋装多封信 Message 一封信 role 为 user 或 assistant;写进去不触发模型,只是存档 Run 办一次事 挂在 Thread 上,指定用 哪个 Assistant 来办 Run Step 流水 一次 Run 内部的每一步 调了什么工具、写了什么 Run Step 是排查用的流水,不是主流程;有的平台压根不单独暴露它。 主流程只依赖前四个对象就够。 一次问答的时间轴 1 创建 Assistant 一次性动作,之后每次问答都跳过这一步 2 创建 Thread 一个客户 / 一个会话建一次,id 务必自己也存一份 3 添加 Message 把用户这句话存进袋子。此刻模型还没有被调用 4 创建 Run 立刻返回 run_id,事情在平台那边异步跑,还没有结果 5 轮询 Run 状态 queued → in_progress → completed;退避着查,别裸 sleep 6 列出 Message 回复不是 Run 的返回值,要回到袋子里去取最新那封信 第二轮只做 3~6 同一个 Thread 再加一封信、再跑一次 Run,历史自动带上,不需要把整段对话重发一遍
图② 四对象的从属关系与一次问答的时间轴

读定义容易滑过去,有三处措辞值得停一下:

A「自动处理内容截断」

Thread 的这句定义,是托管式路线最大的省事之处:对话再长也不用你算 token、不用你决定丢哪几轮。自建时这件事得自己补回来(见 4.4)。

B「助手会向线程追加消息」

回复不是 Run 的返回值,而是被追加进 Thread 的一条新 Message。所以拿结果的动作永远是「回袋子里取最新那封信」。

C「一个实例」

Run 是一次运行的实例,不是助手本身。同一个 Thread 上可以先后跑很多次 Run,每次都可以指定不同的 Assistant。

比喻复述

Assistant 是那份签好的岗位说明书——写明这位助理用什么脑子、按什么规矩办事、手上有哪些权限工具。它跟具体哪位客户无关,所以建一次就够,可以被无数个档案袋复用

Thread 是一个客户的档案袋——它是空的、没有任何配置,只是按顺序装信。Message 是袋子里的一封信,写进去这件事本身不惊动任何人,只是存档。Run 才是你冲后台喊的那一嗓子:「照着这个袋子里的档案办一次」。Run Step 是助理的工作流水:几点几分翻了哪份资料、调了什么工具、写了什么字。

2.2 一次问答的六步

实现流程固定为下面这几步,顺序不能乱:

1创建 Assistant

指定自定义的指令和一个模型,在接口中创建一个 Assistant。如果需要,还可以启用代码解释器、信息检索和函数调用等工具。这是一次性动作,之后每次对话都跳过它。

2创建 Thread

用户开始交谈时,创建一个对话线程 Thread。建议一位客户 / 一个会话一个 Thread,并把返回的 id 存进自己的库。

3添加 Message

用户提出问题时,向对话线程中添加消息 Message。此刻模型还没有被调用,你只是往袋子里塞了一封信。

4创建 Run

对对话线程执行运行 Run 动作,以激活 Assistant 的响应。这一过程会自动调用相关的工具。请求立刻返回一个 run_id,事情还在平台那边跑。

5轮询状态

thread_id + run_id 反复查状态,直到进入终态。要用退避,不要裸 sleep,理由见 2.3。

6取回消息

回到 Thread 上列出消息,最新那条 assistant 消息就是回复。忘了这一步,会出现「状态明明 completed,却什么也没拿到」。

为什么第 3 步和第 4 步是分开的 因为 Message 与 Run 是两个维度:你可以连着塞三封信再跑一次 Run(让助理一次性看完三条补充说明),也可以对同一个袋子反复 Run(换个助理再办一次)。把「存档」和「开工」拆开,正是这套抽象的设计取舍。代价是:多了一次网络往返,而且新手常常忘了喊第二声。

2.3 Run 是一台状态机,而且有一个分支必须你来接

Run 不是「发出去就有结果」。它是有限状态机,典型状态是这么流转的:

queued排队中
in_progress助理正在办
completed办完了,回信已入袋
in_progress办到一半
requires_action要你去执行本地函数
提交工具输出你执行完回传结果
in_progress助理继续办
failed平台侧失败
·
cancelled被取消
·
expired超时过期

requires_action 不是终态。把它当终态处理,是这套接口最常见的错:现象是「状态查到了,消息列表却永远是空的」,程序悄悄地什么都没干。它的含义是:助手决定要调一个你注册的函数,而函数在你的机器上,平台执行不了——铁律在这里依然成立,执行函数的永远是你的代码。托管式接口做的只是把这个交接动作包进了状态机。

轮询也有讲究。写死 time.sleep(2) 有两个毛病:任务两秒内就结束时白等,任务要跑一分钟时三十次请求全打在服务端上。正确写法是指数退避 + 上限 + 总超时

retrykit.py —— 轮询退避与失败重试,托管式接口的标配工具模块
"""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 状态机跑一遍(含 requires_action 分支)可直接运行
"""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"]), "超时过期")
重试要看错误类型 网关 502、连接重置这类瞬时故障才值得重试;参数错、鉴权错重试一百次也是同样的结果,只会白烧配额。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 新旧对象映射:迁移就是查这张表

四个旧对象,各自被谁接了班:

旧对象新对象变化与影响
AssistantsPrompts配置(模型 / 工具 / 指令)变得可版本化、可回滚。但有一条硬限制:Prompts 只能在控制台里创建,不能通过接口创建。所以「程序启动时自动建一个助手」这种写法必须拆掉——改成先在控制台备好、代码里按 id 引用,或者干脆把指令随每次请求传。
ThreadsConversations只能存 message 变成存 item:消息、工具调用、工具输出都是 item。读历史的代码要改:先看 item 的 type,再决定怎么渲染,不能再假设清一色是 message。
RunsResponses改动最大的一格。异步任务变成一次请求:送进 input items、拿回 output items。工具调用循环改为显式自己管——原来等 requires_action 再提交工具输出的那段,换成自己写的 while
Run stepsItems泛化成统一的 item 对象。原来靠列步骤做排查的地方,改成遍历 output item。
四个旧对象,各自被谁接了班 左列是已经下线的托管式对象,右列是现在要用的对象;中间写清变化点 旧:Assistants 系列(已下线) 新:Responses 系列 这一栏才是真正要改的代码 Assistants 模型 / 工具 / 指令打成一个 服务端对象,用接口创建 Prompts 同一份配置可以版本化、 可以回滚到上一版 只能在控制台里创建,不能用接口建。 所以「程序启动时自动建一个助手」这种写法要拆掉: 改成先在控制台备好,代码里按 id 引用,或把指令随请求传。 Threads 只能装 message, 工具过程看不见 Conversations 装的是 item:消息、工具 调用、工具输出都在内 建会话、带会话 id 发请求,这两步几乎一一对应。 读历史时要注意:拿回来的不再是清一色的 message, 遍历时先看 item 的 type,再决定怎么渲染。 Runs 异步任务:提交后轮询, 工具循环由平台内部跑 Responses 送进 input item, 拿回 output item 改动最大的一格:工具调用循环改成自己写。 原来等 requires_action 再提交工具输出的那段, 换成 while:取 function_call → 执行 → 回填 function_call_output。 Run steps 专门的步骤对象 Items 统一成一种泛化对象 原来调步骤列表做排查的地方,改成遍历 output item; 新增的 deep research、MCP、computer use 也走同一套 item。
图③ 旧对象各自被谁接了班:四格映射与真正要改的代码
✅ 迁移的心法 这张表里只有第三行是真正要动脑子的:循环从平台手里拿回来了。前两行是查表替换,第四行是排查手段的改名。把 2.3 那段状态机逻辑,改写成 4.3 里的 while,迁移就完成了七成。

2.7 这套抽象在别处仍然活着

四对象不是 OpenAI 的私产,它是托管式 Agent 的通用形状。国产平台的接口路径几乎逐字对应:

概念典型接口路径(拼 HTTP 那一派)典型 SDK 写法(那一派)
Assistant/v1/assistants/createAssistants.create(...)
Thread/v1/threads/createThreads.create()
Message/v1/threads/messages/addMessages.create(thread_id, ...)
Run/v1/threads/run/createRuns.create(thread_id, assistant_id=...)
轮询/v1/threads/run/retrieveRuns.wait(run_id, thread_id=...)
Run Step(部分平台不单独暴露)Steps.list(run_id, thread_id=...)
取回复/v1/threads/messages/listMessages.list(thread_id)

所以吃透四对象是一笔可迁移的投资:换平台时,你要改的是 base url、鉴权头、字段名,流程一步不少、一步不多。第 04 节会把这两派写法各跑一遍。

03最小代码:两条路各走一遍最短距离

托管式四对象最短版、Responses 最短版,以及把两段并排读出来的差异

3.1 托管式四对象:能跑通的最短版本

先把最短路径跑通,再谈工程化。这份脚本只做一件事:建助手、建线程、放一封信、跑一次、轮询、读回信——四个对象各出场一次,没有文件上传、没有工具、没有重试。

跑之前先把两个环境变量准备好。密钥与租户编号在平台控制台里找到密钥管理相关的功能即可获取,获取后只写进环境变量,不要写进代码

minimax_min.py —— 四对象最小可跑版本最短路径
"""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/listMessage回到袋子里取信。最新那条 role 为 assistant 的消息才是回复
这里有个容易忽略的接口怪癖 查询运行状态那一步用的是 GET 方法却带了 JSON 请求体。这不是笔误,是该平台的接口设计;requests.get(...)data= 能发出去,但有些 HTTP 客户端和网关会把 GET 的 body 丢掉。遇到「参数明明传了,服务端说缺参数」,先怀疑这一点,改用 requests.request("GET", url, data=...) 或按平台文档换成查询参数。

3.2 Responses:同一件事的最短版本

换到继任接口上,同样一件事短成这样:

responses_min.py —— Responses API 最小调用最短路径
"""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 元一斤

同一个需求,下面四个案例沿着三条路线依次往右走:先把状态完全托管出去(案例一、二),再迁到新接口(案例三),最后把状态全部拿回自己家(案例四)。先把三条路线的差别摄在一张图里:

同一件事的三条路:交出去多少,就少写多少代码,也少掌握多少 从左到右,你写的代码越来越多,能带走的东西也越来越多 路线一 零代码平台(GPTs 这一类) 你做什么 在界面里填角色设定、挂知识库、 勾选内置能力,然后发布分享 谁保管状态 全部在平台:配置、会话、文件 分发方式 平台内的链接或商店,用户得先 有这个平台的账号 ✓ 半天出一个可用的助手 ✓ 不用服务器、不用写代码 ✗ 界面长什么样由平台决定 ✗ 平台改规则你只能跟着改 路线二 托管式接口(四对象) 你做什么 写代码调接口:建助手、建线程、 加消息、跑运行、轮询、取回复 谁保管状态 配置与会话仍在平台,你只拿 id 分发方式 你自己的 App、小程序、公众号, 界面完全自定义 ✓ 多轮上下文不用自己存 ✓ 检索、代码执行等开箱即用 ✗ 对象模型是这家平台专有的 ✗ 接口下线时,会话一起没了 路线三 自建(Function Call + 会话表) 你做什么 三张表存会话,一个 while 循环 管工具调用,底座只要 tool calling 谁保管状态 全在自己的数据库里,随时可导出 分发方式 任意;换模型厂商只改 base_url 与模型名 ✓ 没有哪家能把它下线掉 ✓ 数据、日志、成本都看得见 · 上下文截断、并发、重试要自己写 · 检索、沙箱执行要自己接或自己搭 选哪条不取决于哪条更「高级」,取决于这套东西要活多久、数据能不能留在自己手里
图④ 三条路线对比:零代码平台、托管式接口、自建

一个容易被念反的结论:这三条路不是技术水平的高低。选哪条取决于两件事:这套东西要活多久,以及数据能不能留在自己手里。

4.1 案例一:托管式全流程(上传文件 + 检索 + 轮询)

这是托管式路线的完整形态,比 3.1 的最小版多了三件在真实项目里必须有的东西:文件上传(让助手能检索业务资料)、双重返回值检查退避轮询

流程一共七步:

0上传文件

把价目表传给平台,拿到 file_idpurpose 声明这个文件给谁用,助手检索场景固定填 assistants

1创建 Assistant

模型、名字、描述、instructions,挂上 file_ids,并在 tools 里启用 retrieval

2创建 Thread

空袋子,只拿 id。

3添加 Message

把老板的问题作为一封 user 信件存进袋子。

4创建 Run

喊一声开工,拿到 run_id

5轮询状态

退避着查到终态;非 completed 的终态一律当失败处理。

6取回消息

列出袋子里的全部消息,最新那条 assistant 消息就是答复。

·贯穿全程

每一步都检查返回值;任一步失败立刻抛错并说清是哪一步,不要带着空 id 往下跑。

envkit.py —— 密钥读取与打码,所有脚本共用工具模块
"""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 —— 水果店收银助手:托管式七步全流程完整案例
"""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))

几处值得单独说的实现细节

A上传文件时不要自己写 Content-Type

上传走 multipart,手动设置 Content-Type: application/json 会让 requests 无法补上 multipart 的 boundary,服务端直接解析失败。所以 _headers(json_body=False) 单独准备了一份不带该字段的请求头。

BHTTP 200 不等于成功

这类接口常见 HTTP 200 但业务码非 0。只看 status_code 会把失败当成功,一路跑到最后才发现 id 是空串。_check() 同时看 HTTP 码和业务返回里的状态字段。

C建助手之后可能需要等一会儿

挂了文件的助手,平台要做向量化与存储,刚建好就跑 Run 有可能检索不到内容。稳妥做法不是盲目 sleep,而是先去查助手状态确认就绪,或在 Run 失败时按退避重试。

D把 id 打印出来并落盘

file_idassistant_idthread_idrun_id 是你和平台之间唯一的对账凭据。打日志、进数据库,缺一个后面都查不下去。

输出长什么样

跑通之后,最后一步打印的是整个消息列表的 JSON。助手写的那条内容大意如下(换一次模型、换一次提问,措辞都会变,但结构是固定的:先列价格,再逐项算,最后汇总):

助手回复(节选) 根据价目信息:葡萄成本 2 元/斤、售价 4 元/斤;香蕉成本 2 元/斤、售价 3 元/斤;苹果成本 3 元/斤、售价 3.5 元/斤。
总成本 = 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 写法完整案例
"""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/createAssistants.create(model=..., tools=[...])
错误处理看 HTTP 码 + 业务码SDK 把状态码挂在返回对象上,不抛异常,每步都要自己查
轮询自己写循环Runs.wait(...) 已封装好,但它也会在 requires_action 时返回
看流水部分平台不暴露Steps.list(run_id, thread_id=...)
工具类型平台自定义平台自定义——这是迁移时最先崩的地方
SDK 不抛异常这件事要特别小心 这一派 SDK 把 HTTP 状态码放在返回对象上,失败时不会中断程序。漏掉检查的典型症状是:前面某一步其实失败了,后面拿 res.id 得到 None,报错却出现在毫不相干的地方。所以代码里每一步都套了 verify(),并把步骤名一起打出来。

再看一眼两家的工具类型:一家叫 retrieval,另一家提供的是自家搜索工具、文生图工具。工具清单是平台专有的,四对象流程能平移,工具能力不能。做选型时,这一栏要单独列出来评估。

4.3 案例三:迁到 Responses + Conversations

现在把同一个助手迁到继任接口上。先看多轮会话怎么接:

responses_conversation.py —— 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 —— 工具调用循环改成显式自己管迁移写法
"""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_actionoutput 里出现 function_call 类型的 item
你执行函数完全一样——执行的永远是你的代码
回传结果调「提交工具输出」接口,带 tool_call_idfunction_call_output 作为新的 input item 再发一次,带 call_id
继续下一轮Run 自动回到 in_progress你的 while 再转一圈
什么时候停Run 进入终态output 里不再出现 function_call
防死循环平台侧有上限自己写 for turn in range(...) 兜底

把旧写法和新写法并排放在一个文件里,迁移要改哪几行最直观:

migrate_side_by_side.py —— 同一需求,旧写法与新写法并排迁移对照
"""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)
迁移清单(照着勾) ① 把「程序启动时建助手」的代码删掉,改成控制台里备好配置、代码按 id 引用,或把 instructions 随请求传;② Thread 相关调用换成 Conversations;③ 把轮询那一整段删掉,换成一次同步调用;④ 把等 requires_action 的分支重写成 whilefunction_call;⑤ 读历史的地方加上 item 类型判断;⑥ 排查用的步骤列表换成遍历 output item。

4.4 案例四:不依赖任何托管——会话表 + 工具循环

前面三条路都有同一个前提:状态在别人家。这一条把它搬回来。要补的东西其实只有三张表:

一行是什么关键字段
agents一个 Assistant模型、instructions、工具清单——岗位说明书进了自己的库,顺带就有了版本控制的可能
threads一个档案袋归属的 agent、标题、创建时间
messages一封信seq 保序、rolecontent,外加一个 extra_json 原样存 tool_calls / tool_call_id
selfhost_store.py —— 三张表,标准库 sqlite3 就够自建方案
"""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 循环自建方案
"""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))
同一个应用,会话状态放在哪一侧,决定了它能不能搬家 虚线是「平台边界」。落在虚线右侧的方块,都不是你的资产 A 状态托管出去 你的服务只是一个转发层,去掉它应用照样在,去掉平台应用立刻死 用户端 App / 小程序 公众号 / 网页 你的服务 存了什么: assistant_id、thread_id 再无其他 平台边界 平台保管的会话状态 Thread / Message / Run 上下文截断策略 上传的文件与索引 接口下线时会发生什么 历史会话列不出来 助手配置需要重建 转发层几乎要重写 B 状态留在自己这边 平台只剩下一个职责:给一次模型推理。这一层谁都能换 用户端 App / 小程序 公众号 / 网页 你的服务 会话表:agents / threads / messages 工具循环也在这里 平台边界 平台只负责一件事 收 messages,返回一条 回复或一组工具调用 无状态,可随时替换 接口下线时会发生什么 改 base_url 与模型名 历史会话一条不少 业务代码基本不动
图⑤ 架构对比:状态托管出去 vs 状态留在自己这边

自己存,多写了什么,换回了什么

事项托管式替你做自建时你要做
多轮上下文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 —— 把托管会话导回自己的库退出通道
"""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")
这里有个前提,缺了就全盘作废 thread_id 必须平时就存在你自己的库里。只存在平台控制台里的会话,接口一关就再也列不出来了——你连要导什么都不知道。所以案例一的代码里,每一个 id 都被打印出来;真实项目里它们应该进数据库,而不是滚进日志。

归一层(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 —— 托管式四对象通用骨架,只改 TODO 处可复用模板
"""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))
✅ 复制后你要改的地方 TODO 1~2 换 base url 与租户参数 · TODO 3~8 按平台文档替换六个接口的路径与字段名 · TODO 9~13 换助手名、模型名、岗位说明书、工具类型和第一句话。其余代码不用动。
模板里刻意留了一个坑不填 TERMINAL_BAD 里包含 requires_action,也就是说:模板遇到「需要你执行函数」会直接报错退出。这是故意的——用到函数调用时,你必须自己在这里补上「执行本地函数并提交工具输出,然后继续等」的分支,而不是让它悄悄地被当成失败。参照 run_state_machine.py 里的 drive() 写法。

5.2 可搬家的自建骨架

这份模板的全部价值在一个词:可替换。一个抽象层 Backend,两个实现——会话存自己库里的 LocalBackend,会话交给平台的 HostedBackend。业务层只调 Agent.say(),换后端是一行配置的事。

portable_agent_skeleton.py —— 一个抽象层 + 两个后端,随时可搬家可复用模板
"""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_URLLLM_MODEL 两个环境变量
托管式接口停服AGENT_BACKENDhosted 改成 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 公开接口给平台做体检可直接运行
"""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)
许可证这一项常被读错 「基于 Apache-2.0」不等于「就是 Apache-2.0」。实际存在这样的情况:主体沿用 Apache-2.0,但附加了商用限制条款——比如未经书面授权不得用其源码运营多租户环境,以及使用其前端时不得移除或修改控制台与应用中的 LOGO 和版权信息。这类条款直接决定「能不能拿它对外卖服务」,必须读原文,不能看摘要

闭源托管接口没有仓库可查,换三个问题:官方文档里有没有一个专门的弃用页面?历史上下线接口时给了多长的过渡期有没有配套的迁移指南和对象映射表?三个都有,说明这家把废弃当正事办——托管式 Agent 接口这次换代,恰好就是一个正面样本:提前一年公告,到期执行,附带逐对象的映射说明。

数字都会变 任何一次体检拿到的都是某一时刻的快照。把这条命令写进项目的 README 或例行检查清单,每季度自己跑一次,比记住任何一个具体数字都有用。

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)?正确写法包含哪三件东西?

两个毛病:任务两秒内结束时白等;任务跑一分钟时几十次请求全打在服务端上。还有第三个更危险的 —— 平台故障时这个循环永远不会退出

正确写法三件套:指数退避(间隔逐渐变长,带抖动避免惊群)、间隔上限(别退避到几分钟才查一次)、总超时(超时抛错,不要无限等)。另外只对瞬时故障重试,参数错和鉴权错重试无意义。

三、迁移
四个旧对象分别被谁接了班?哪一格改动最大?

AssistantsPrompts(配置可版本化、可回滚,但只能在控制台创建,不能用接口建
ThreadsConversations(从只存 message 变成存 item
RunsResponses(送进 input items、拿回 output items,工具调用循环改为显式自己管
Run stepsItems(泛化成统一对象)

改动最大的是 Runs → Responses 这一格:循环从平台手里拿回来了,轮询那一整段要删掉,等 requires_action 的分支要重写成自己的 while

迁移后,「模型要求调函数」这件事在代码里长什么样?

旧:Run 进入 requires_action,你调「提交工具输出」接口回传,运行自动继续。

新:output 里出现 typefunction_call 的 item;你执行函数后,把 {"type": "function_call_output", "call_id": ..., "output": ...} 作为新的 input item 再发一次请求;循环终止条件是 output 里不再出现 function_call

两件事不变:执行函数的永远是你的代码;回填必须靠 id 配对。新增的一件事:自己写轮次上限兜底,防止模型反复调同一个函数造成死循环。

四、选型与自建
把会话状态托管出去,具体丢掉了什么?自己存又要补写哪些东西?

丢掉的:完整的往来档案、助手的工作流水、任何本地备份。你手上只剩 assistant_idthread_id 两个字符串;接口下线时历史会话列不出来、助手配置要重建、转发层几乎要重写。

自己存要补:多轮上下文拼装(约十行)、上下文截断策略(条数上限 / 按 token 裁剪 / 超长摘要)、工具调用循环(约二十行)、知识库要自己接、代码沙箱要自己搭。

换回来的:换模型厂商只改 base_url 与模型名、全部会话一条 SQL 就能导出、程序中途崩溃能接着跑、没有人能把它下线

怎么判断一个平台还值不值得押上业务?至少说出三个可执行的动作。

开源平台查三个字段:最后提交时间(最重要,长期不动的别押业务)、关注度(仅作参考)、许可证(返回 NOASSERTION 说明不是标准协议,必须读 LICENSE 原文,重点看商用范围、多租户限制、LOGO 与版权信息条款)。

闭源托管接口换三个问题:官方文档里有没有专门的弃用页面?历史上下线接口给过多长过渡期?有没有配套迁移指南与对象映射表?

还有一个动作与平台无关但最管用:先把退出通道跑通一次 —— thread_id 存在自己库里,写好导出脚本,真的导一次并确认内容能读。没演练过的备份等于没有备份。

术语表

术语含义
GPTs无需写代码即可创建的定制化 AI 助手,靠平台内置的分享功能分发
Assistant托管在平台上的助手对象:模型 + instructions + tools,建一次可被多个会话复用
Thread助手与用户之间的对话会话,存储消息并自动处理内容截断以适应上下文限制
Message由助手或用户创建的消息,以列表形式存储在线程上;写入不触发模型
Run在某个 Thread 上调用某个 Assistant 的一次实例;异步执行,会向线程追加消息
Run Step一次 Run 内部的详细步骤列表,用来内省助手如何得出最终结果
requires_actionRun 的中间状态,表示需要开发者执行本地函数并提交工具输出;不是终态
Code Interpreter托管式工具之一:在平台沙箱里真的执行一段代码
Retrieval托管式工具之一:把挂载的文件切片建索引,回答时自动检索
Function calling模型输出要调用的函数名与参数,执行仍由开发者的代码完成
Responses API接替托管式助手接口的调用方式:送进 input items,拿回 output items,同步返回
Conversations API接替 Thread 的会话容器,存的是 item 而不只是 message
Prompts接替 Assistant 的配置对象,可版本化、可回滚;只能在控制台创建
ItemResponses 体系里的统一对象:消息、工具调用、工具输出都是 item
function_call_output新接口里回填工具执行结果的 input item 类型,靠 call_id 与调用配对
指数退避轮询或重试时让间隔逐渐变长并加抖动,配合上限与总超时使用
供应商锁定把配置、会话、数据托管在某一家平台后,难以低成本迁走的状态
退出通道事先备好并演练过的迁出方案:自己记 id、定期导出、后端可替换
✅ 一句话收束本讲 托管式 Agent 把「配置、会话、运行」整体外包,换来的是速度,付出的是控制权。四对象值得吃透,因为它是这条路线的通用形状;但档案袋存在中介手里——中介关门那天,你的上下文跟着一起消失。把 id 记在自己库里、把会话表准备好、把后端做成可替换的,这三件事做了,走哪条路都不怕。