跳到主要内容

模型抽象层:统一接口与多 provider

30 秒导读: Strands 号称「model-agnostic(与模型无关)」——同一段 agent 代码,底层可以是 Bedrock、Anthropic、OpenAI、Gemini、Ollama…… 换模型只改一行。这靠的是一个抽象基类 Model(abc.ABC):它把「和一个大模型对话」这件事,压成 4 个抽象方法 + 2 个属性 + 1 个默认实现。每个 provider 只要实现这套接口、把它翻译到自家 SDK,就能无缝插进 agent 事件循环。本章讲清这套抽象长什么样、Bedrock 这个默认实现怎么落地、以及各 provider 的差异点在哪。


1. 这是什么(为什么需要一层抽象)

先说痛点。 每家模型厂商的 SDK 长得都不一样:Bedrock 叫 converse_stream、Anthropic 叫 client.messages.stream、OpenAI 是 chat.completions.create。请求格式、流式事件、错误类型、工具 schema 全都对不上。如果 agent 主循环直接调某一家的 SDK,那这段代码就和那家厂商焊死了。

这一层要解决的就是它: 在 agent 主循环和五花八门的厂商 SDK 之间,插一层统一的窄接口。主循环只认这层接口;每个厂商写一个「适配器」把接口翻译到自己的 SDK。

  • 一句话定义: Model 是所有模型 provider 的抽象基类,定义了「配置模型 + 流式对话 + 结构化输出」的标准契约。
  • 给谁用 / 解决什么: 让 agent 作者不必关心底层是谁;让新 provider 的接入只是「填一个类」。
  • 一句话直觉: 把它想成数据库里的 ODBC/JDBC——上层写一套 SQL,底层换 MySQL 还是 Postgres 只换一个驱动。这里「SQL」就是 stream(),「驱动」就是 BedrockModelAnthropicModel……

用起来什么样——换 provider 只动构造那一行,Agent(...) 和后面所有代码都不变:

# 示意,非源码:model-agnostic 的直观体现
from strands import Agent
from strands.models import BedrockModel, AnthropicModel

model = BedrockModel(model_id="global.anthropic.claude-sonnet-4-6") # 默认 provider
# 想换成 Anthropic 直连?只改这一行:
# model = AnthropicModel(model_id="claude-sonnet-4-6", client_args={"api_key": "..."})

agent = Agent(model=model) # 下面全部与 provider 无关
agent("帮我把这个函数改成异步的") # 主循环只调 model.stream(...),不认识 boto3/anthropic

本节不碰底层细节。记住一点:下面所有内容,都是在解释「那一行之下、主循环之上」的这层薄抽象怎么工作。


2. 顶层全景(它大概怎么转)

一次模型调用在这层里的流向,是「统一请求下沉、厂商 SDK 干活、统一事件上浮」:

统一世界(与 provider 无关) 厂商世界(各家 SDK)
┌───────────────┐ messages/tool_specs ┌──────────────────┐ converse_stream() ┌──────────┐
│ agent 事件循环 │ ───────────────────────▶│ 某个 Model 子类 │ ─────────────────────▶│ 厂商 API │
│ (第1章 主线) │ 调 model.stream() │ (如 BedrockModel) │ format_request() │ Bedrock… │
└───────────────┘ └──────────────────┘ └──────────┘
▲ │ 厂商原生 chunk │
│ 标准 StreamEvent 事件流 │ format_chunk() 翻译回统一格式 │
└────────────────────────────────────────────┴◀──────────────────────────────────────┘
messageStart / contentBlockDelta / messageStop / metadata …

怎么读这张图: 左到右是「下沉」——统一的 messages/tool_specsformat_request 变成厂商请求;右到左是「上浮」——厂商的原生流式 chunk 经 format_chunk 翻译回一套标准事件再交还主循环。抽象层的全部工作,就是这两次翻译。

各部件职责:

部件干什么在哪
Model(abc.ABC)定义统一契约:4 抽象方法 + 2 属性 + 默认 count_tokensmodels/model.py:161
stream() 契约产出标准 StreamEvent 事件流,与第1章 streaming 对接types/streaming.py:208 StreamEvent
provider 子类把统一接口翻译到自家 SDK(默认 BedrockModel)models/bedrock.py:84
format_request / format_chunk请求下沉翻译 / 事件上浮翻译(约定俗成,非抽象)各 provider 文件内
_validation / _strict_schema配置键校验、strict JSON schema 转换等共享工具models/_validation.pymodels/_strict_schema.py
ModelRetryStrategy节流(throttling)时按指数退避重试event_loop/_retry.py:21

主线走一遍(高层): 主循环拿着 messages + tool_specsmodel.stream(...) → 子类 format_request 把它翻成厂商请求 → 调厂商 SDK 拿到原生流 → 逐块 format_chunk 翻回标准 StreamEventyield 回主循环,由主循环组装成消息 / 触发工具。


3. 统一接口:Model(abc.ABC)

这节讲契约本身——所有 provider 必须遵守的最小面。它就定义在 models/model.py:161class Model(abc.ABC)

3.1 四个抽象方法 + 两个属性

抽象基类刻意做得很:只强制四个方法,子类不实现就无法实例化(abc.ABC 的语义)。

成员类型位置作用
stream(...)抽象方法model.py:230核心:把一轮对话变成标准事件流(见 §4)
structured_output(...)抽象方法model.py:209让模型产出符合某个 Pydantic 模型的结构化结果
update_config(**cfg)抽象方法model.py:189运行期改配置(温度、model_id 等)
get_config()抽象方法model.py:199取回当前配置
stateful属性(默认 False)model.py:169模型是否服务端托管会话状态
context_window_limit属性model.py:178上下文窗口 token 上限(读配置)
count_tokens(...)默认实现(可覆盖)model.py:266发送前估算 token 数(见 §3.2)

stream 是心脏。 它的签名把「一轮对话」需要的一切摆平了:messagestool_specssystem_prompt,以及 tool_choicesystem_prompt_contentinvocation_state 等仅限关键字参数:

# 真实签名节选,models/model.py:230 Model.stream
def stream(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
*, # ← 之后全是仅限关键字(keyword-only)
tool_choice: ToolChoice | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
invocation_state: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[StreamEvent]: ...
  • 那个裸 * 是刻意的约定: system_prompt 之后的参数一律仅限关键字。这样以后加新选项,不会打乱主循环的调用点——这是「统一接口能演进而不破坏调用方」的关键设计。
  • 返回类型基类写 AsyncIterable[StreamEvent],provider 覆盖时收窄成 AsyncGenerator[StreamEvent, None]。子类用 @override 并配 # type: ignore[override],因为它收窄了 **kwargs(见 bedrock.py:230update_config)。

structured_output(model.py:209) 要求「模型输出必须匹配给定的 output_model: type[T](一个 pydantic.BaseModel)」;它同样是异步生成器,最后一个 yield 的事件带 {"output": <实例>},前面的事件是普通流。做法各家不同(见 §6)。

stateful(model.py:169)默认 False 它是 model-agnostic 里一个精妙的钩子:大多数模型是无状态的(每轮都要把完整历史传回去),但少数 provider(如 OpenAI Responses API,openai_responses.py:195 覆盖了这个属性)在服务端记住会话。§8 会讲这个属性如何驱动一次消息清理。

context_window_limit(model.py:178) 只是读配置里的 context_window_limit(定义在 BaseModelConfig,model.py:121),给上下文管理(如到阈值触发压缩,见第5章)用。

3.2 默认 count_tokens:启发式兜底

要解决的小问题: 主动上下文管理需要「在发送前」知道大概多少 token,好决定要不要压缩。但不是每个 provider 都有 token 计数 API。

思路: 基类给一个依赖无关的启发式兜底——有 tiktoken 就用它,没有就按字符数硬估。count_tokens(model.py:266)默认就走这条兜底,provider 可以覆盖成原生 API(Bedrock 就覆盖了,见 §5)。

启发式的核心是几个小函数,规则很直白:

函数位置规则
_heuristic_estimate_textmodel.py:27文本按 字符数 / 4 向上取整
_heuristic_estimate_jsonmodel.py:32JSON 对象按 序列化后字符数 / 2
_count_content_block_tokensmodel.py:40拆开一个内容块,分别对 text / toolUse / toolResult / reasoning / 引用等累加
_estimate_tokens_with_heuristicmodel.py:91遍历所有消息 + tool_specs + system,汇总

一个关键取舍写在注释里:toolResult 里的图片 / 文档是二进制,启发式故意不计(model.py:62)。文档也诚实说明:精度因模型而异,不用于计费或精确配额(model.py:277)。

3.3 缓存配置:CacheConfig / CacheToolsConfig

抽象层还提供两个跨 provider 的缓存配置数据类(prompt caching = 让厂商缓存重复的前缀,省 token/延迟):

数据类位置字段干什么
CacheConfigmodel.py:132strategy("auto"/"anthropic")、ttl自动注入 cachePoint 的策略与过期时间
CacheToolsConfigmodel.py:148type("default")、ttltoolConfig 那块单独设缓存点

它们是普通 @dataclass(不是 wire shape,所以不用 TypedDict),被 provider 的配置引用——例如 BedrockConfig.cache_config(bedrock.py:142)。真正「往请求里注入缓存点」的逻辑在各 provider,见 §5 的 _inject_cache_point


4. stream() 的标准事件契约

这节讲上浮方向的统一格式——无论底层是谁,stream() 吐出来的事件都长同一个样,这样第1章的流式回合逻辑才能与 provider 解耦。

标准事件类型全都定义在 types/streaming.py:208StreamEvent(一个 total=False 的 TypedDict,建模自 Bedrock 的 Converse API——所以 Bedrock 是「最省翻译」的一家):

事件键含义对应类型
messageStart一条消息开始(带 role)MessageStartEvent (streaming.py:16)
contentBlockStart一个内容块开始(如工具调用的 id/name)ContentBlockStartEvent (streaming.py:26)
contentBlockDelta增量:文本 / 工具输入 / 推理 / 引用ContentBlockDeltaEvent (streaming.py:123)
contentBlockStop一个内容块结束ContentBlockStopEvent (streaming.py:136)
messageStop消息结束(带 stopReason)MessageStopEvent (streaming.py:147)
metadatausage / metrics / traceMetadataEvent (streaming.py:159)
redactContent护栏触发时的内容抹除RedactContentEvent (streaming.py:195)

一次典型回合,provider 必须按这个次序把厂商流翻译出来:

messageStart
└─ contentBlockStart ─▶ contentBlockDelta × N ─▶ contentBlockStop (可重复:文本块、工具块…)
messageStop (stopReason)
metadata (usage / metrics)

这就是「契约」的意义:主循环只写一次「拼装这串事件」的逻辑,底层换谁都不用改。Bedrock 因为标准事件本就仿它,format_chunk 几乎是直传;其他家则要把自己的 SSE/事件对象映射成这套键(见 §6)。structured_output 复用了这条流:它内部调 stream(),再经 process_stream(event_loop/streaming.py:394)聚合(见 §5)。


5. 参照实现:BedrockModel(默认 provider)

这节把默认实现读透——class BedrockModel(Model)(bedrock.py:84)。它是 Agent() 不指定模型时的兜底,也是理解「统一接口如何落到一家 SDK」的最佳样本。

5.1 配置与默认模型

配置是嵌套 TypedDict BedrockConfig(BaseModelConfig, total=False)(bedrock.py:96),继承了公共的 context_window_limit,再加一堆 Bedrock 特有项(guardrail、cache、strict_toolsservice_tier…)。

构造(bedrock.py:164)只接 boto 相关参数 + **model_config,先 validate_config_keys 校验键,再存进 TypedDict。默认 model id 由 _get_default_model_with_warning(bedrock.py:1248)按 region 前缀推断(us/eu/ap),常量 DEFAULT_BEDROCK_MODEL_ID = "global.anthropic.claude-sonnet-4-6"(bedrock.py:42);不认识的 region 会告警并提示显式传 model_id

5.2 下沉翻译:format_request

format_request(bedrock.py:249)是「统一 → Bedrock」的核心。它把 messages/tool_specs/system 拼成 Converse 请求,几处值得注意:

  • 工具 schema 直接用统一格式——tool_spec["inputSchema"](Bedrock 本就用这套),仅当开了 strict_tools 才过 ensure_strict_json_schema 并加 strict: True(bedrock.py:300-305)。
  • 缓存点注入:_inject_cache_point(bedrock.py:404)在 strategy="auto" 且模型是 Claude 系时,把 cachePoint 追加到最后一条 user 消息(_cache_strategy,bedrock.py:219)。
  • 严格字段过滤:_format_bedrock_messages(bedrock.py:452)会逐块删掉 Bedrock 不认识的字段——因为 Bedrock 对未知字段直接抛校验异常(注释见 bedrock.py:468),这与「其他 API 忽略未知字段」不同,是这家的坑。

5.3 异步↔同步桥:stream + _stream

boto3 是同步的,而抽象接口是 async。Bedrock 用「后台线程 + 队列」搭桥:

stream() [async] _stream() [在 asyncio.to_thread 里同步跑]
│ 建 asyncio.Queue │ format_request()
│ create_task(_stream) ───────────────▶ │ client.converse_stream(**request)
│ │ for chunk in stream: callback(chunk)
│ while: event = await queue.get() ◀──── callback: loop.call_soon_threadsafe(put)
│ yield event │ finally: callback(None) # 哨兵=结束
▼ ▼
  • stream(bedrock.py:897)开一个 asyncio.Queue,把阻塞的 _stream 丢进 asyncio.to_thread,自己 await queue.get() 逐个 yield;callback(None) 是结束哨兵(bedrock.py:929)。
  • _stream(bedrock.py:956)按 streaming 配置走 converse_stream(流式)或 converse(非流式,再由 convert_non_streaming_to_streaming(bedrock.py:1072)手工拼出 §4 那串标准事件)。
  • 这正是[第3章外]AGENTS 约定的「阻塞调用包进 asyncio.to_thread、绝不阻塞事件循环」。

5.4 错误映射:厂商异常 → 统一异常

provider 的一个硬责任:别让原始厂商异常逃出边界_streamexcept ClientError(bedrock.py:1017)做映射:

Bedrock 错误映射成位置
ThrottlingExceptionModelThrottledExceptionbedrock.py:1024
命中 BEDROCK_CONTEXT_WINDOW_OVERFLOW_MESSAGES 里的文案ContextWindowOverflowExceptionbedrock.py:1026
AccessDenied / 无效 model id 等原样抛,但 add_exception_note 附排查链接bedrock.py:1033+

映射后的 ModelThrottledException 正是 §7 重试策略要拦的东西。

5.5 结构化输出:强制工具法

Bedrock(和 Anthropic)没有原生 JSON 模式,于是用一个通用技巧——把目标 Pydantic 模型变成一个工具,强制模型「必须调这个工具」:

# 真实做法节选,bedrock.py:1197 BedrockModel.structured_output
tool_spec = convert_pydantic_to_tool_spec(output_model) # Pydantic → 工具 schema
response = self.stream(
messages=prompt, tool_specs=[tool_spec],
tool_choice=cast(ToolChoice, {"any": {}}), # 强制:必须调某个工具
)
async for event in streaming.process_stream(response): # 复用第1章的聚合器
yield event
stop_reason, messages, _, _ = event["stop"]
# 从 toolUse.input 里取出结构化结果,校验 stop_reason=="tool_use",
# 最后 yield {"output": output_model(**output_response)} # bedrock.py:1246

重点看:结构化输出不是另起一套调用,而是「强制工具 + 复用 stream() + 复用 process_stream」——工具 schema 怎么从 @tool/Pydantic 来,是第2章的事。

5.6 原生 token 计数(覆盖默认)

Bedrock 覆盖了 count_tokens(bedrock.py:815):当 use_native_token_count=True 时调 Bedrock 的 count_tokens API 拿准确值;权限不足或模型不支持时,把该 model id 记进 _SKIP_COUNT_TOKENS_MODELS 缓存并回落到父类的启发式(super().count_tokens(...),bedrock.py:895)。这是「默认实现 + 可选覆盖」协作的范例。


6. 多 provider 对照表:同一接口,各自翻译

所有 provider 实现同一套 Model 契约,差异全在「怎么把统一接口翻译到自家 SDK」。下表按几条主轴对照(逐个从各文件核对):

Provider文件底层 SDK结构化输出方式tool_choice
Bedrock(默认)bedrock.py:84boto3 converse_stream强制工具 + 解析 toolUse支持
Anthropicanthropic.py:40anthropic SDK强制工具(同 Bedrock,anthropic.py:511)支持
OpenAIopenai.py:53openai SDK原生 response_format=output_model(openai.py:857)支持
LiteLLMlitellm.py:37(继承 OpenAIModel)litellm双路:支持则原生 schema,否则退回工具法(litellm.py:391)继承 OpenAI
Geminigemini.py:32google-genai原生 response_schema+response_mime_type(gemini.py:614)自有转换
SageMakersagemaker.py:92(继承 OpenAIModel)SageMaker endpointresponse_format json_schema strict(sagemaker.py:595)不支持(告警)
Ollamaollama.py:28ollama SDKformat= schema不支持(告警)
Mistral / Writer / LlamaAPI / llama.cpp各文件各自 SDKresponse_format / 工具 / 有限支持多数不支持(告警)

从这张表能看出三种「翻译策略」:

  1. 几乎直传(Bedrock)——标准事件本就仿 Bedrock,format_chunk 最省事。
  2. 子类复用(LiteLLM、SageMaker 继承 OpenAIModel)——OpenAI 兼容的一大票 provider 共享同一套 format_request_*
  3. 完全另写(Anthropic、Gemini、Ollama)——各自实现 format_request / format_chunk,把自家事件对象逐字段映射回 §4 的标准键。

strict JSON schema(_strict_schema.py:18 ensure_strict_json_schema) 是 provider 差异的一个缩影:开 strict 模式时要给每个 object"additionalProperties": false;但 OpenAI 还额外要求把所有属性塞进 required,于是用一个参数 require_all_properties 区分——文档明说「OpenAI 需要,Bedrock/Anthropic 不需要」(_strict_schema.py:33)。它还处理 $defs/$ref/anyOf/allOf/oneOf 的递归。

_validation.py 是共享的小工具带:

  • validate_config_keys(_validation.py:13):把传入配置键和 TypedDict 字段比对,不认识的键告警(不报错),提示可用键。
  • warn_on_tool_choice_not_supported(_validation.py:34):Ollama/Mistral/Writer/SageMaker 等不支持 tool_choice 的 provider,收到该参数时统一告警并忽略——统一接口收下了这个参数,但 provider 诚实告知「我不认」。

7. 重试策略:ModelRetryStrategy

模型被节流(HTTP 429 → §5.4 映射成的 ModelThrottledException)很常见。重试不写死在 provider 里,而是做成一个hook 提供者 ModelRetryStrategy(HookProvider)(event_loop/_retry.py:21),挂在 agent 的 AfterModelCallEvent 上(控制面 hooks 见第4章)。

  • 指数退避:_calculate_delay(_retry.py:68)= initial_delay * 2**attempt,封顶 max_delay。默认 max_attempts=6, initial_delay=4, max_delay=240,即 4s → 8s → 16s → 32s → 64s(第 6 次不再退避)。
  • 只对节流重试:_handle_after_model_call(_retry.py:92)只在异常是 ModelThrottledException 时置 event.retry=Trueawait asyncio.sleep(delay);成功或其他异常则重置计数(_reset_retry_state)。
  • 一次调用结束后(AfterInvocationEvent)也会重置状态,保证下次调用从零开始。

这是又一处「机制与 provider 解耦」:任何抛出统一 ModelThrottledException 的 provider,都自动享有同一套退避,无需各自实现。


8. _ModelPlugin:以插件形式接入 agent(略提)

抽象层还带一个小插件 _ModelPlugin(Plugin)(model.py:295),负责「模型相关的生命周期钩子」。它目前只做一件事,恰好演示了 stateful 属性的用途:

# 真实逻辑,model.py:303 _ModelPlugin._on_after_invocation
if event.agent.model.stateful: # 模型在服务端托管会话?
event.agent.messages.clear() # 那就清掉本地消息历史,避免重复上送

init_agent(model.py:317)把这个回调注册到 AfterInvocationEvent。对无状态模型(默认 stateful=False)什么都不做;对服务端有状态的模型(如 OpenAI Responses),每轮结束清空本地历史。插件如何统一装配进 agent,属于第4章 控制面


9. 巧妙之处(可借鉴)

  • 窄接口 + 约定俗成的辅助方法。 抽象基类只强制 4 个方法;format_request/format_chunk约定而非抽象——既统一了心智模型,又不把每个 provider 的形状焊死。(model.py:161 vs 各 provider)
  • 标准事件仿最强的一家。 StreamEvent 直接建模 Bedrock Converse API(streaming.py:2),让默认 provider 几乎零翻译,其他家再向它对齐——省去发明中立格式的成本。
  • 同步 SDK 用「线程 + 队列 + 哨兵」优雅异步化。 bedrock.py:897stream/_stream 桥,是把任何阻塞 SDK 塞进 async 世界的可复制模板。
  • 结构化输出复用工具链。 不新造 API,而是「强制工具 + process_stream」(bedrock.py:1216),一套代码同时服务对话与结构化输出。
  • 能力差异靠告警而非报错。 不支持 tool_choice 的 provider 收下参数再告警(_validation.py:34),让统一接口保持宽,同时保持诚实。

10. 边界与局限(诚实)

  • count_tokens 是估算,不用于计费(model.py:277);启发式对图片/文档二进制不计(model.py:62),跨模型精度参差。
  • tool_choice 并非所有 provider 支持——Ollama/Mistral/Writer/SageMaker 等只会告警并忽略(§6)。
  • strict 模式的 schema 要求各家不同,需要 require_all_properties 这类分支手工兼容(_strict_schema.py),新增 provider 时是易错点。
  • Bedrock 对未知字段零容忍,必须逐块过滤(bedrock.py:468),这类「一家一个脾气」的坑无法完全抽象掉。
  • 默认重试只覆盖节流;其他瞬时错误(网络抖动等)不在 ModelRetryStrategy 的范围内(_retry.py:127)。

11. 代码地图(导航索引)

主题文件路径符号名
抽象基类(契约)strands-py/src/strands/models/model.pyModel
抽象方法:流式对话strands-py/src/strands/models/model.pyModel.stream
抽象方法:结构化输出strands-py/src/strands/models/model.pyModel.structured_output
属性:是否服务端有状态strands-py/src/strands/models/model.pyModel.stateful
属性:上下文窗口上限strands-py/src/strands/models/model.pyModel.context_window_limit
默认 token 估算strands-py/src/strands/models/model.pycount_tokens / _estimate_tokens_with_heuristic / _count_content_block_tokens
缓存配置strands-py/src/strands/models/model.pyCacheConfig / CacheToolsConfig / BaseModelConfig
模型生命周期插件strands-py/src/strands/models/model.py_ModelPlugin
标准事件契约strands-py/src/strands/types/streaming.pyStreamEvent
默认 providerstrands-py/src/strands/models/bedrock.pyBedrockModel
请求下沉翻译strands-py/src/strands/models/bedrock.pyBedrockModel.format_request / _format_bedrock_messages
异步↔同步桥strands-py/src/strands/models/bedrock.pyBedrockModel.stream / _stream
强制工具结构化输出strands-py/src/strands/models/bedrock.pyBedrockModel.structured_output
strict JSON schema 转换strands-py/src/strands/models/_strict_schema.pyensure_strict_json_schema
配置校验 / tool_choice 告警strands-py/src/strands/models/_validation.pyvalidate_config_keys / warn_on_tool_choice_not_supported
节流重试(指数退避)strands-py/src/strands/event_loop/_retry.pyModelRetryStrategy
OpenAI 原生结构化输出strands-py/src/strands/models/openai.pyOpenAIModel.structured_output
LiteLLM 双路结构化输出strands-py/src/strands/models/litellm.pyLiteLLMModel.structured_output
Gemini 原生结构化输出strands-py/src/strands/models/gemini.pyGeminiModel.structured_output

相邻章节:index · 01 agent 事件循环 · 02 工具系统 · 04 控制面 · 05 上下文与持久化 · 06 沙箱与多 agent