把模型调用抽成 4 个对象,换厂商只改一行配置

AgentScope 2.0 实践:把模型调用抽成 4 个对象,换厂商只改一行配置

接第三家模型的时候,我发现自己在前两家写的东西几乎要重写一遍。

不是业务逻辑要改------业务逻辑一行没动。要改的是凭证类名、模型类名、base_url、参数名,每家一套。

这篇文章记录我怎么把这块抽成 4 个对象,以及三个设计上的取舍。其中一个是我至今认为不够优雅的 workaround,我会明确标出来。


一、问题:多厂商接入的成本被低估了

大部分「统一接口」的宣传,说的是调用方式统一:

python 复制代码
response = await agent.reply(msg)

这句话确实统一。但在这句之前,接入方式是分裂的:

  • 有的厂商有原生 SDK 封装,凭证和模型都是一等对象;
  • 有的厂商走 OpenAI 兼容协议,得手动指定 base_url;
  • 有的厂商用自己的一套鉴权和模型命名。

如果你只接一家,这不算问题。一旦要接三家做横向对比、或者给客户留切换余地,成本就暴露了:接入是 N 份工作量,调用才是 1 份。

我的目标很具体:让「换厂商」这件事,收敛到只改一处配置。


二、方案:四层拆分

2.1 结构

markdown 复制代码
Credential(凭证)  →  Model(模型)  →  Agent(智能体)  →  Msg(消息)
     API Key            推理引擎         名字+人设+大脑        带 role 的输入

四层的职责边界很清晰:

层 职责 变化频率
Credential 鉴权,证明「我是谁」 低(换 Key 才动)
Model 推理,决定用哪个模型 中(换模型/换厂商)
Agent 角色,决定说话风格 高(调人设)
Msg 输入,一次对话内容 最高(每次请求)

按变化频率分层,是这套结构能站住的原因:变得最频繁的东西放在最外层,最稳定的东西放在最里层。 换人设不用碰模型,换模型不用碰鉴权。

2.2 三个取舍

取舍一:Credential 独立成对象,还是直接在 Model 里传 api_key

选了独立对象。

理由是三点:Key 集中管理,不会散落在业务代码里;可以统一做脱敏和注入(我这边全部走环境变量);未来接密钥管理服务时,只改 Credential 一层。

代价是代码变长。只接一家模型的小脚本,api_key="sk-xxx" 直接传更省事,独立一层纯属多余。

判断标准:接 2 家以上,或者有测试/生产多套 Key,就值得独立;只接 1 家且短期不换,别抽。

取舍二:stream=False 还是 stream=True

入门阶段选了 False。

两个原因:一次拿完整回复,处理逻辑简单(不用管 chunk 拼接);规避了流式响应未消费完导致的连接残留问题------这个问题的后果在第四节展开。

代价很明确:失去首字延迟优势。长回答场景下,用户要盯着空白等十几秒,体验是倒退的。

这不是最佳实践,是阶段性选择。 上生产必须改回 True,并正确消费整个流。我在文末的边界一节会再强调一次。

取舍三:手写 close_models(),还是交给框架 / GC

选了手写,但这是 workaround,不是设计。

AgentScope 这一层没有提供统一的模型关闭入口,三个模型各自持有自己的 openai.AsyncClient。不关的话,程序退出时会报 generator didn't stop after athrow()。

所以写了个显式收尾:

python 复制代码
async def close_models(models):
    """在事件循环关闭前,逐个关闭底层 HTTP 连接池"""
    closed = 0
    for m in models.values():
        client = getattr(m, "client", None)     # 依赖私有属性,见「边界」
        if client is None:
            continue
        try:
            await client.close()                # 异步方法,必须 await
            closed += 1
        except Exception:
            pass
    return closed

好处是可控、可观测(能打印「关了几个」)。坏处是它依赖 self.client 这个私有属性------框架升级改了内部结构,这段就静默失效(getattr 返回 None,一个都不关,也不报错)。

如果你要抄这段,建议改成失败即告警 ,别用 pass 吞掉异常。


三、核心实现

完整代码在仓库里(见文末),这里只留决策点。

3.1 三家厂商的装配

python 复制代码
# DeepSeek:原生凭证 + 原生模型
deepseek = DeepSeekChatModel(
    credential=DeepSeekCredential(api_key=os.environ["DEEPSEEK_API_KEY"]),
    model="deepseek-v4-flash",
    stream=False,
)

# 腾讯混元:OpenAI 兼容协议,必须显式给 base_url
hunyuan = OpenAIChatModel(
    credential=OpenAICredential(
        api_key=os.environ["HUNYUAN_API_KEY"],
        base_url="https://tokenhub.tencentmaas.com/v1",
    ),
    model="hy3",
    stream=False,
)

# 阿里百炼:DashScope 原生
bailian = DashScopeChatModel(
    credential=DashScopeCredential(api_key=os.environ["BAILIAN_API_KEY"]),
    model="qwen-max",
    stream=False,
)

这段的关键不是写法,是装配完之后它们对外是同一个类型语义 ------都可以直接塞进 Agent(model=...)。

3.2 Agent 装配

python 复制代码
agent = Agent(
    name="小秘",
    model=model,
    system_prompt="你叫「小秘」,是一位亲切、耐心的技术学习助手。",
)

三个参数各管一件事:name 是身份标识(多 Agent 场景下用于路由),model 是大脑,system_prompt 是性格。

3.3 取回复:必须处理分块

python 复制代码
for block in response.content:
    if getattr(block, "type", None) == "text":
        return block.text

response.content 是列表不是字符串。现在的块类型有文本、图片、工具调用,未来还会增加。任何把 content 当字符串用的代码,都会在加工具调用那天崩掉。

这一条值得写进团队规范:取文本一律走遍历 + 类型判断,不要 str(response.content)。


四、横向对比

4.1 接入方式

厂商 模型类 凭证类 需手填 base_url 备注
DeepSeek DeepSeekChatModel DeepSeekCredential 否 模型名已更新为 deepseek-v4-flash,旧名 deepseek-chat 会 404
腾讯混元 OpenAIChatModel OpenAICredential 是 走 OpenAI 兼容协议
阿里百炼 DashScopeChatModel DashScopeCredential 否 环境变量名易与 DASHSCOPE_API_KEY 混用

三家接法三种,调用一种。这个表是我建议在选型文档里保留的------它能提前暴露「接第 N 家要多少工作量」。

4.2 实测 Token

同一个问题(「请用一句话说说:什么是多智能体编排?」),三家各跑一次:

模型 输入 Token 输出 Token 回答风格
DeepSeek deepseek-v4-flash 111 36 用「导演」打比方
腾讯混元 hy3 122 24 用「指挥小团队」打比方
阿里百炼 qwen-max 128 25 书面定义,无比喻

三点观察:

  1. 输入 Token 差 17 个(111 vs 128),差异来自各家 tokenizer 对中文的切分口径,不是 prompt 长度不同------同一个 prompt,切出来的 token 数本来就不一样。做成本核算时不能直接拿 token 数横向比价,得按各家单价换算。
  2. 输出 Token 差 12 个(24 vs 36),这个差异是真实的风格差异:会打比方的模型输出更长。
  3. 回答准确度没有可感知差异。在这个任务上,选模型的依据是风格和单价,不是能力。

五、边界与不足

这部分是我认为比正文更值得看的部分。

这套方案不适用的场景:

  1. 生产环境的长文本输出。 stream=False 会让用户干等,必须改流式。
  2. 高并发场景。 单次 asyncio.run() 起一个循环跑完就退,没有连接复用。要做服务化,得把模型对象提到应用生命周期里,全局复用连接池。
  3. 需要重试/超时/限流的生产链路。 本文代码没有任何重试逻辑,一次失败就抛。真实业务至少要加指数退避和超时。
  4. 多 Agent 协作。 这里只有一个 Agent,name 字段在多 Agent 路由里的作用没有体现。

已知的技术债:

  • close_models() 依赖 m.client 私有属性,框架升级会静默失效(前面说过,建议改成失败告警)。
  • stream=False 是规避问题的权宜之计,不是解法。
  • 环境变量名不统一(BAILIAN_API_KEY vs DASHSCOPE_API_KEY),纯靠文档约定,没有代码层校验。

未验证的部分:

  • 并发调用同一个模型实例是否线程/协程安全
  • 长会话下的内存增长
  • 各家 SDK 在弱网下的重试行为

这些我没测,不写结论。


六、结论

四层拆分的实际收益,不是「代码更好看」,是变化被限制在一层里:

  • 换厂商 → 只改 build_models()
  • 换人设 → 只改 system_prompt
  • 换输入结构 → 只改 Msg

如果你的项目要接两家以上模型,或者需要给客户留切换余地,这个结构值得抄。如果只接一家、三个月内不换,直接传 api_key 更省事。

别为了架构而架构------但要知道自己省掉的是什么。


七、代码仓库

完整可运行代码(001 最小 Agent + 后续 13 个示例 + 6 个踩坑复现脚本):

复制代码
agentscope-2.0-cookbook/
├── examples/01_basics/001_min_agent.py
├── examples/02_tools_rag/
├── examples/03_multi_agent/
├── examples/04_evaluation/
├── examples/05_deployment/
└── examples/06_pitfalls/

仓库地址见评论区置顶或我的掘金主页。示例都是单文件自包含,pip install agentscope 之后可以直接跑。

下一篇讲多轮对话的状态管理:agent.reply() 本身无状态,要让 Agent 记住上下文,消息列表得自己管------包括截断策略和 Token 预算。

相关推荐
武子康2 小时前
Coding Agent 的知识也会漂移:用固定快照检查 Vercel Plugin 升级
人工智能·后端·agent
阿里云大数据AI技术2 小时前
云栖实录|Hologres 5.0全新发布:Agent Native 的全模态实时数仓
大数据·人工智能·agent
CoovallyAIHub3 小时前
用 AI 做 PPT 被辞退,法院判公司赔:员工贴进对话框的数据,到底谁负责?
前端·agent
CoovallyAIHub3 小时前
Anthropic 昨晚发布 Claude Duet:AI 第一次主动找你干活
github·agent·设计
花椒技术3 小时前
花椒 AI 应用工程化实践:灵泡泡的工作流、版本管理与任务恢复
后端·agent·ai编程
SelectDB3 小时前
以实时驱动智能:Apache Doris Summit 2026 盛大落幕,开启 Agentic AI 时代数据新范式
数据库·程序员·agent
灵动的艺术4 小时前
如一 Agent 架构解读(十二):记忆系统(下)——记忆的生命周期工程
人工智能·agent·如一
一个处女座的程序猿4 小时前
Agent之GPT-6:GPT-6.1 Sol 解读—让高阶 AI 能力以更低成本落地
人工智能·agent·gpt-6.1 sol