接入第一个大模型时,事情往往很简单:安装 SDK,填入 API Key,把消息发出去,再把返回的文字显示出来。
接入第二家以后,问题才开始变得有意思。
两家都支持聊天,也都支持流式输出,但消息怎么传、工具调用怎么返回、思考强度怎么设置,可能各有一套写法。即使都兼容 OpenAI API,细节也未必相同。业务代码里很容易开始出现这样的判断:“如果是这家,就多传一个字段;如果是那家,就从另一个位置取结果。”
我在设计一个对话应用时,希望把这些差异收在一个独立的 AI 层里。应用负责组织对话,AI 层负责完成模型调用。以后换一个应用,这部分代码仍然能拿过去使用。
本文讨论的就是这个边界:如何给调用方一个稳定的接口,同时保留不同模型真实存在的能力差异。设计参考了 pi 的 AI 包,实现采用 Python。下面会用具体接口解释设计,但重点放在这些接口为什么需要存在,以及它们分别解决什么问题。
1. 多模型接入,难在哪里?
假设应用要让模型回答一句“解释一下递归”。只看最终文字,几家服务似乎没有什么区别。可一旦把完整过程展开,差异就出来了。
输入可能包含系统指令、图片、历史回复和工具结果。输出可能先出现文字,再出现一个工具调用;工具参数还是分几个片段传回来的。一次请求也可能在已经返回一半内容后断开。
调用方需要知道的不只是“返回了什么”,还包括“现在发生到哪一步”和“最后为什么停止”。
我把需要处理的问题分成三组:
表达差异。 同一段对话,在不同 API 里可能使用不同的消息结构;同一个思考强度,也可能放在不同字段中。
能力差异。 某个模型能接收图片,另一个只能接收文字;某个模型允许关闭思考,另一个始终会思考。
运行过程。 流式结果如何消费,请求如何取消,客户端何时关闭,失败以后能否重试。
这三组问题不能用同一种办法处理。表达差异适合转换,能力差异需要明确展示,运行过程则需要一套稳定的约定。
如果把它们都塞进一个“通用参数字典”,调用方虽然少看见几个类型,却要自己记住每家供应商的例外。复杂度并没有消失。
2. 先划清边界:这一层负责到哪里?
在写接口之前,我先确定了一件事:AI 层收到的内容,应该已经是上层决定要交给模型看的内容。
例如,一个模拟面试应用可能有身份设定、面试要求、用户简历、历史消息和压缩后的摘要。选择哪些材料、按什么顺序组合、什么时候压缩,属于上下文管理。什么时候开始一次生成、工具执行后是否继续调用,属于上层运行时的调度。
AI 层接收组织好的输入,把它转换成目标供应商的请求,然后把输出整理成调用方能处理的结果。
它仍然会处理消息格式。例如,系统指令应该放进哪个字段,工具结果怎样关联之前的工具调用,图片怎样编码。这属于请求适配,不意味着它要替应用决定保留哪段历史。
整体边界可以画成下面这样。图中的箭头表示使用关系,具体请求过程会在后面单独展开。
flowchart TB
subgraph APP[应用]
direction LR
C[上下文管理]
R[runtime 调度]
T[工具执行]
R --> C
R --> T
end
subgraph AI[独立 AI 层]
M[Models:查询与调用入口]
P[Provider:供应商组合]
A[认证解析]
D[API 协议适配器]
M --> P
P --> A
P --> D
end
R --> M
D --> SDK[供应商 SDK]
SDK --> S[模型服务]这个边界对工具调用尤其重要。模型返回“调用某个工具”时,AI 层负责整理工具名称、调用 ID 和参数,也可以校验参数是否符合声明。真正执行工具、处理权限、决定是否继续下一轮,仍然由应用负责。
这样做以后,AI 包就不必依赖应用的数据库、Web 框架或语音模块。它可以被命令行程序、后台任务或另一个 Web 应用复用。独立性首先来自职责,放进单独的目录只是最后一步。
3. 用 Model、Provider、Models 组织接入能力
这一层的核心结构围绕三个对象展开:Model、Provider 和 Models。名字看起来接近,但它们解决的是不同问题。
Model:描述这次要调用什么
Model 是一份模型描述,包含模型 ID、所属供应商、使用的 API 协议、服务地址,以及图片、工具、思考档位等能力信息。
它本身不持有连接,也不保存密钥。
把这些信息放在一起,是为了让一次调用的目标足够明确。调用方拿到一个 Model,就能知道要用什么模型,以及它提供哪些选项。实际发送时,也不用再到几个全局变量里拼凑模型 ID 和地址。
这里有两个字段需要特别区分:
model.provider # 供应商身份,例如 deepseek
model.api # API 协议,例如 openai-completionsDeepSeek 可以使用 OpenAI 兼容协议,但请求仍然发往 DeepSeek,使用的也仍然是 DeepSeek 的凭据。协议名称不能替代供应商身份。
Provider:把一家供应商需要的能力组合起来
一个 Provider 提供模型目录、认证方式和可使用的协议适配器。
例如,创建 DeepSeek Provider 时,就把它的模型描述、密钥读取方式、默认服务地址和兼容配置组合好。应用通常只需要配置 API Key,再从目录中选择模型,不必每次手写这些信息。
provider = create_deepseek_provider()创建 Provider 和查询目录都不需要向供应商请求模型列表。这里的目录是包内维护的静态数据;真正发起生成请求时,才需要有效凭据。
认证也单独处理。请求可以明确提供凭据,否则从凭据存储或供应商对应的环境变量读取。这样,模型目录可以安全地展示给应用,而不用把密钥混进模型配置。
Models:给调用方一个稳定入口
Models 管理当前应用注册的 Provider,并提供模型查询、认证解析和调用入口。
models.set_provider(provider, owns_provider=True)
available = models.get_models("deepseek")
model = models.get_model("deepseek", "deepseek-flash")这里的 "deepseek" 是供应商 ID。先找到对应 Provider,再从它的目录中查找模型。
模型目录服务于“发现与选择”。发起调用时,Models 根据传入 Model 的供应商身份找到 Provider,Provider 再根据 API 协议选择适配器。目录不承担调用白名单的职责;应用也可以自行构造一个完整的 Model,再交给已经注册的 Provider。
这个安排保留了两个独立的扩展方向:新增一家使用现有协议的供应商,通常只需要组合配置;新增一种协议,则需要实现新的适配器。如果新供应商存在独有的认证或请求行为,也要补相应支持,不能仅凭“兼容”两个字就假定全部可用。
供应商与协议分开以后,这两种变化就不必都挤进同一批条件分支。
4. 一次模型调用,应该提供怎样的接口?
先统一输入和结果
调用方传入的是 Context:系统指令、消息和工具声明。消息使用 UserMessage、AssistantMessage、ToolResultMessage 等明确的类型,内容可以包含文字或图片。
这份输入表达的是“要交给模型的信息”。它还不是可以直接发送的 HTTP 请求。不同协议对消息、系统指令和工具定义的要求,留给适配器处理。
返回结果也不能只有一个字符串。AssistantMessage 需要容纳文字、思考内容、工具调用、用量和停止原因。否则,应用遇到工具调用或中途失败时,仍然得重新钻进 SDK 的返回对象里查找细节。
与此同时,有些供应商返回的信息只有再次调用同一个模型时才有意义,例如思考内容对应的签名。这些信息也不能简单丢掉。统一消息结构可以保留协议相关的附加数据,让后续调用继续使用;换模型时,再由适配器判断哪些内容可以重放。
流式事件与最终结果,分别服务于什么?
应用通常有两种需要:界面要尽快显示新文字,保存记录时又要拿到一份完整结果。
因此,流对象同时提供异步迭代和 result()。前者观察过程,后者取得最终消息。
下面是一段完整的使用示例。它需要安装相应 SDK,并提前配置 DEEPSEEK_API_KEY。
import asyncio
from ai import Context, Models, SimpleOptions, UserMessage
from ai.providers.deepseek import create_deepseek_provider
async def main():
async with Models() as models:
models.set_provider(
create_deepseek_provider(),
owns_provider=True,
)
model = models.get_model("deepseek", "deepseek-flash")
if model is None:
raise ValueError("模型不在当前目录中")
stream = models.stream_simple(
model,
Context(messages=[UserMessage("解释一下递归。")]),
SimpleOptions(reasoning="low"),
)
async for event in stream:
if event.type == "text_delta":
print(event.delta, end="", flush=True)
result = await stream.result()
print("\n停止原因:", result.stop_reason)
if result.error is not None:
print("调用失败:", result.error.message)
asyncio.run(main())result() 等待的是同一次请求的结果,不会为了得到完整消息再调用一次模型。如果应用只需要最终结果,可以直接使用 complete_simple()。
一次调用的主要过程如下。图中把 SDK 合并在适配器一侧,避免让传输细节遮住调用关系。
sequenceDiagram
participant App as 调用方
participant Models as Models
participant Provider as Provider
participant Adapter as 协议适配器与 SDK
participant Service as 模型服务
App->>Models: Model、Context、调用选项
Models-->>App: 返回流对象
Models->>Provider: 解析认证并发起调用
Provider->>Adapter: 选择协议,传入模型与凭据
Adapter->>Service: 发送协议请求
loop 接收流式输出
Service-->>Adapter: 上游事件
Adapter-->>Models: 统一事件
Models-->>App: 从流中读取事件
end
Models-->>App: result() 返回最终 AssistantMessage如果供应商提供的工具参数是分片 JSON,适配器会累计原始片段,并在参数完整后提供解析结果。应用可以观察工具调用正在生成,但不能拿半截 JSON 当成已经可执行的参数。
为什么还要保留协议专用入口?
统一参数适合常见调用,但供应商经常提供独有设置。例如缓存方式、推理摘要,以及某些特定的生成参数,很难都塞进一个通用选项类。
所以接口分成两组:
使用方式 | 流式结果 | 完整结果 |
|---|---|---|
通用参数 |
|
|
协议专用参数 |
|
|
两组入口使用相同的输入和返回契约,差别主要在调用选项。这样,应用在使用某个原生功能时,不必连消息处理、取消和结果消费也一起绕开。
对于选项类尚未覆盖的请求字段,还保留 on_payload 这样的 Hook。它允许调用方在发送前修改请求。这个入口能提供灵活性,但使用者需要了解目标协议,因此它更适合少量明确的扩展需求。
5. 统一到什么程度,才不会损失模型能力?
我认为,多模型接入里最容易过度设计的地方,就是试图把所有能力统一成完全一样的操作。
思考强度是一个很好的例子。一个模型可能提供 low、high、max,另一个可能还有 medium、xhigh。如果所有模型都显示同一套档位,库就必须决定:用户选了一个不支持的值,到底应该怎么替换?
替换成更低的档位,可能影响回答质量;替换成更高的档位,又可能增加耗时和费用。即使请求成功,用户真正选中的设置也已经变了。
让选项跟着模型走
这套设计选择把实际选项交给调用方:
model.reasoning_levels
# DeepSeek 示例:('off', 'low', 'high', 'max')应用根据当前模型显示选项。用户选哪个强度,就发送哪个强度。换模型以后,仍可用的选择可以保留;已经不可用的选择,由应用恢复为默认设置,并让用户看见变化。
AI 层只处理本次调用,不替应用记住或调整上次的选择。如果直接调用代码传入了模型不提供的值,就在发送请求前返回明确错误。
这里仍然需要协议转换。例如,同样选择 high,OpenAI Responses 使用 reasoning.effort,DeepSeek Chat Completions 使用 reasoning_effort,Anthropic Messages 使用 output_config.effort。字段位置不同,但强度值不变。
这种方式比较容易解释,也比较容易排查请求。代价是,上层不能假设所有模型都拥有同一组控制项。未来如果接入只接受思考预算的模型,就要提供预算设置,或者明确使用协议专用参数。
默认设置和关闭思考,要分开表达
这两个意思经常被一个空值混在一起:
SimpleOptions() # 使用默认设置
SimpleOptions(reasoning="off") # 明确关闭思考
SimpleOptions(reasoning="max") # 明确选择 max不填写时,先采用 Model 配置的默认值。如果模型配置也没有默认值,就不发送相关设置,交给供应商处理。供应商的默认行为可能仍然会思考。
off 则是一条明确指令。只有允许关闭的模型才提供这个选项。适配器负责把它写成对应 API 接受的形式:有的接口使用 none,有的使用 thinking.type="disabled"。
我们沿用了 pi 的 off 选项,但内部传递方式有所不同。pi 的 Agent 会把 off 转成未设置 reasoning,再由适配器处理;这里始终保留显式的 off,直到构造请求时才转换。这样,不填写和明确关闭就不会混用。
pi 还提供了统一档位到不同模型控制方式的映射。它适合希望跨模型保留一种强度偏好的调用方,例如程序自动切换模型时,仍然尽量维持“多思考一些”的设置。
直接使用模型原生选项,和维护一套跨模型映射,都有各自的用途。这里更看重用户选择与实际请求一致,因此没有把映射做成默认行为。如果以后需要自动路由多个模型,可以在那个明确的使用场景中设计转换策略。
模型目录是一份声明,不是一张通行证
目录能告诉应用有哪些模型、模型声明了哪些能力,以及应该怎样调用。它不能保证当前账户已经获得权限,也不能证明某个密钥仍然有效。
容量和价格同样如此。知道一个模型的上下文窗口,并不意味着调用层已经具备准确计算 token 或压缩上下文的能力。价格随条件变化时,也不应该随便填入一个固定值,再给应用一种精确计费的错觉。
把声明的能力、账户的权限和实际调用结果分开,很多含糊的“模型可用”判断就能解释清楚。
6. 流式调用背后的运行机制
模型请求开始以后,还会出现一组与供应商参数无关、但很影响应用体验的问题。
停止读取,不等于停止生成
假设用户关闭了聊天窗口,负责显示文字的循环退出了。这个动作是否应该顺便取消模型请求?
答案取决于应用。有些应用希望关闭窗口后继续生成并保存结果,有些应用则希望立即停止。因此,流对象不根据“有没有人继续读”猜测调用方的意图。
调用方使用单独的取消信号控制生成。下面的片段假定 models、model 和 context 已经准备好:
from ai import AbortController, SimpleOptions
controller = AbortController()
stream = models.stream_simple(
model,
context,
SimpleOptions(signal=controller.signal),
)
# 用户点击停止时,由应用调用:
controller.abort()
result = await stream.result()只从异步循环里 break,不会自动取消生成。取消等待 result() 的那个任务,也不等于取消底层请求。这样,停止显示、停止等待和停止生成就有了各自明确的含义。
取消信号会传到实际请求任务,并关闭对应的本地响应资源。不过,本地结束请求不构成供应商停止计算或退款的保证,这部分仍由服务端处理。
当前流使用主动生产的事件队列,请求不等待调用方开始迭代才启动。这个方式适合同时消费过程和最终结果,但也有明确的成本:队列没有容量上限,消费者长期落后会积累事件。它也不是广播,同一条流上的多个迭代器会竞争读取。需要多个订阅者时,应用应安排一个消费者,再自行分发。
谁创建资源,谁负责关闭?
SDK 客户端通常持有连接资源。多个调用可能复用它,因此不能在每次请求结束后都关闭,也不能让任何一个使用者随意关闭共享对象。
这里采用明确的所有权规则。注册 Provider 时,默认只是借用;传入 owns_provider=True,才表示将关闭责任交给 Models。
这就是前面例子中那句代码的含义:
models.set_provider(provider, owns_provider=True)退出 async with Models() 时,Models 会取消自己的活动请求,并关闭自己拥有的 Provider。Provider 再关闭它拥有的适配器资源。适配器自己创建的 SDK 客户端由它关闭,外部注入的客户端默认由外部管理。
这样,一个 Provider 可以被多个 Models 借用。共享它的应用必须保证:所有使用者结束之后,真正的拥有者才能关闭它。关闭责任是明确约定,不靠引用关系猜测。
错误与重试,需要保留过程信息
一次流式请求可能已经返回部分文字,随后才出现网络错误。如果把错误简单转成一个空字符串,应用既不知道出了什么问题,也不知道之前收到的内容该怎么处理。
因此,请求失败会形成带错误信息的最终消息,并保留已经收到的内容。调用方查看停止原因,就能区分正常结束、达到输出上限、工具调用、取消和失败。错误信息中保留状态码、请求 ID 等可用线索,避免要求业务代码直接依赖某家 SDK 的异常类型。
这也不意味着任何错误都被吞进消息。例如,已经关闭的 Models 被再次使用,或者输入结构不满足入口契约,仍可能直接抛出异常。调用过程失败与接口使用错误,需要分别处理。
重试则更谨慎。尚未进入流式响应时,连接建立失败或明确允许重试的拒绝,可以按配置重试。已经返回了一半内容后,库不会自动重新生成,再把两次结果拼成一条回答。
整次生成是否重试,应由应用明确决定。否则,重复文字还只是表面问题;如果已经执行过工具,重新生成可能改变后续动作。
7. 如何判断这套设计真的有效?
我会从两个方向验证:调用行为是否正确,扩展时是否仍能维持原来的边界。
请求和结果,都要看得到
测试多模型接入时,只验证一个假的 SDK 返回了预设文本,证明不了适配器真的构造了正确请求。
更有价值的做法是运行真实的 Models、Provider、适配器和 SDK,只在 HTTP 服务边界提供可控响应。这样可以直接检查发出去的地址、认证头、消息结构和控制参数,也可以观察上游事件怎样变成统一结果。
例如,验证关闭思考时,应该检查实际请求里有没有关闭指令,以及是否错误地带上了开启时的强度。验证不支持的档位时,除了看错误消息,还应该确认请求根本没有发出去。
对于流式输出,普通文字只是起点。工具参数分片、工具结果重放、收到部分内容后失败、取消发生时的资源释放,都值得用具体场景验证。测试场景来自接口承诺,而不是为了把每个内部方法都调用一遍。
离线测试与真实联调,回答不同的问题
离线测试能稳定验证请求构造和事件处理,但不能证明账户权限、真实服务对字段的支持程度,以及网络中的取消行为都符合预期。
因此,真实模型联调仍然必要。它应该显式启用,选择具体供应商和模型,并控制调用次数与成本。离线测试负责让代码行为可重复,真实联调负责确认代码与服务能够协作。
看新增供应商时,改动落在哪里
还有一种检验设计的办法:观察新增能力时需要修改哪些模块。
如果新增一家使用现有协议的供应商,主要变化落在模型目录、认证和兼容配置中,而统一调用入口不需要增加供应商判断,说明结构达到了预期。
如果新增一种协议,则应该在适配器里处理新的请求和事件形式。上层仍然读取统一消息、观察流式事件、使用同一套取消机制。
当然,任何扩展都不可能永远只加配置。新的模型能力可能要求扩展消息结构或调用契约。关键在于能说清楚:这次变化来自新的能力,还是旧边界没有划好。
这也是我希望独立 AI 层保持的状态。调用方能用稳定的方式完成一次请求,又能明确知道当前模型有哪些选项;适配器处理协议细节,应用保留自己的上下文、调度和工具执行策略。等到下一家供应商、下一种模型真正进入项目时,再沿着这些边界扩展。