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 | 书面定义,无比喻 |
三点观察:
- 输入 Token 差 17 个(111 vs 128),差异来自各家 tokenizer 对中文的切分口径,不是 prompt 长度不同------同一个 prompt,切出来的 token 数本来就不一样。做成本核算时不能直接拿 token 数横向比价,得按各家单价换算。
- 输出 Token 差 12 个(24 vs 36),这个差异是真实的风格差异:会打比方的模型输出更长。
- 回答准确度没有可感知差异。在这个任务上,选模型的依据是风格和单价,不是能力。
五、边界与不足
这部分是我认为比正文更值得看的部分。
这套方案不适用的场景:
- 生产环境的长文本输出。
stream=False会让用户干等,必须改流式。 - 高并发场景。 单次
asyncio.run()起一个循环跑完就退,没有连接复用。要做服务化,得把模型对象提到应用生命周期里,全局复用连接池。 - 需要重试/超时/限流的生产链路。 本文代码没有任何重试逻辑,一次失败就抛。真实业务至少要加指数退避和超时。
- 多 Agent 协作。 这里只有一个 Agent,
name字段在多 Agent 路由里的作用没有体现。
已知的技术债:
close_models()依赖m.client私有属性,框架升级会静默失效(前面说过,建议改成失败告警)。stream=False是规避问题的权宜之计,不是解法。- 环境变量名不统一(
BAILIAN_API_KEYvsDASHSCOPE_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 预算。