跳到主要内容

数据截至 (上游 commit 460c729002dc)

第 3 章 · 统一 LLM 层(Backend)

本章讲什么: 第 2 章的通行证要求「这一轮必须调 think 工具」。可是 Ollama 的 OpenAI 兼容端点根本不认 tool_choice 这个参数。BeeAI 怎么让同一份约束在两种模型上都生效?这一章就答这个问题。


3.1 ChatModel 承担了什么

ChatModel(backend/chat.py:252)是个抽象类,子类只需实现两个方法:

# python/beeai_framework/backend/chat.py:407-451 签名
async def _create(self, input: ChatModelInput, run: RunContext) -> ChatModelOutput # 非流式
def _create_stream(self, input: ChatModelInput, run: RunContext) -> AsyncGenerator[...] # 流式

剩下的全在基类里做完了:

关切谁负责
入参归一_prepare_model_input(chat.py:453-502)
缓存CacheEntry + self.cache
重试Retryable(chat.py:542-549)
事件start / new_token / success / error / finish
空响应处理EmptyChatModelResponseError + 自动重试
工具调用合法性校验_assert_tool_response(chat.py:864-924)
坏 JSON 修复_fix_tool_calls(chat.py:683-704)
能力降级_force_tool_call_via_response_format(chat.py:797-820)

最后一项是本章主角。


3.2 问题:各家的 tool_choice 支持度不一样

tool_choice 有四种模式,框架用一个集合声明每个 provider 支持哪些:

# python/beeai_framework/backend/chat.py:298
tool_choice_support: ClassVar[set[ToolChoiceType]] = {"required", "none", "single", "auto"}
模式含义
auto模型自己决定调不调
required必须调某个工具,调哪个随意
single必须调指定的那一个工具
none不准调工具

真实的 provider 声明差异很大:

Providertool_choice_support出处
默认(OpenAI 等)四种全支持backend/chat.py:298
Ollama空集合 set()adapters/ollama/backend/chat.py:19
watsonx{"none", "single", "auto"}(无 required)adapters/watsonx/backend/chat.py:19
AgentStack 里的 Cerebras / Together / RITS{"none", "single", "auto"}adapters/agentstack/backend/chat.py:51-69
OpenAI 但配了自定义 base_url自动摘掉 requiredadapters/openai/backend/chat.py:55-57

最后一条特别务实:你把 base_url 指向某个 OpenAI 兼容的第三方网关时,框架默认那个网关未必实现了 required,主动降级。


3.3 降级路径:用结构化输出伪造工具调用

触发条件

# python/beeai_framework/backend/chat.py:804-820 逻辑摘要
if (没有工具 or tool_choice ∈ {none, auto, None} or 用户自带 response_format
or 关掉了 tool_call_fallback_via_response_format):
return False # 不需要降级

tool_choice_supported = (tool_choice 是 Tool ? "single" in 支持集 : tool_choice in 支持集)
return (not model_supports_tool_calling) or (not tool_choice_supported)

一句话:只有在「用户明确要求了强制,而模型撑不住」时才降级。

对照第 2 章:RequirementAgent 每轮都会给出 tool_choice="required" 或某个具体工具。配上 Ollama 的空支持集,这条降级路径在本地小模型上是常态,不是边角

降级怎么做:把工具表编译成一个联合类型

核心是 generate_tool_union_schema(backend/utils.py:125-201)。它给每个工具造一个两字段模型:

# python/beeai_framework/backend/utils.py:136-148 结构
create_model(tool.name,
name=(Literal[tool.name], Field(description="Tool Name")),
parameters=(tool.input_schema, Field(description="Tool Parameters")))

然后把它们并成一个联合类型,再包成 response_format:

工具表 [think, openmeteo, final_answer]

▼ 每个工具 → {name: Literal["think"], parameters: ThinkSchema}

▼ Union[...] 三选一

├─ 支持顶层 union? → RootModel[Union[...]]
└─ 不支持? → WrappedRootModel(套一层 {"item": ...})

▼ 允许并行调用? → list[Union[...]]


{"type": "json_schema", "json_schema": {"name": "ToolCall", "schema": {...}}}

模型现在面对的不是「调工具」,而是「输出一个符合这个 schema 的 JSON」——后者几乎所有模型都会。

三个工程细节:

  • allow_top_level_union=False 的兜底。 有些 provider 的 JSON schema 实现不接受顶层 anyOf,于是套一层 {"item": ...}(WrappedRootModel)。
  • $ref 全部内联。 inline_schema_refs(utils.py:114-122)用 jsonref$defs 展开——很多 provider 不解引用。
  • 严格模式复用 OpenAI 的实现。 to_strict_json_schema 直接从 openai.lib._pydantic 引入(utils.py:8)。

回程:把 JSON 还原成工具调用消息

模型返回文本后,基类把它翻译回标准的 tool-call 消息:

# python/beeai_framework/backend/chat.py:634-665 摘要
text = result.get_text_content()
tool_calls_raw = parse_broken_json(text) # 先修 JSON
tool_calls = response.response_format_schema.model_validate(tool_calls_raw) # 再按 schema 校验
for tool_call in cast_list(tool_calls.model_dump()):
final_message.content.append(MessageToolCallContent(
id=f"call_{generate_random_string(8).lower()}", # 自己发 id
tool_name=tool_call["name"],
args=to_json(tool_call["parameters"], ...)))

从这一步之后,上层完全看不出这次调用是原生的还是伪造的——第 1 章的 Runner 拿到的都是一模一样的 MessageToolCallContent。抽象泄漏被堵在了这一层里。


3.4 fallback_tool:模型忘了写工具名怎么办

弱模型的典型失误:你要它输出 {"name": "final_answer", "parameters": {"response": "..."}},它输出 {"response": "..."} ——参数对了,壳子没了。

框架在生成的 schema 类里重写了 model_validate,在校验前先补壳:

# python/beeai_framework/backend/utils.py:169-188 摘要
if not isinstance(obj, dict):
obj = {"name": fallback_tool.name, "parameters": obj}
if BaseClass is WrappedRootModel:
... # 补上 {"item": ...}
if obj.get("name") is None:
obj["name"] = fallback_tool.name # 名字缺了就当是兜底工具
if "parameters" not in obj:
obj["parameters"] = exclude_keys_inplace(obj, set(obj.keys()) - {"name"}) # 把散落字段收进 parameters
return super().model_validate(final, **kwargs)

谁当兜底工具:agent 传一次,backend 再补一次

Agent 侧只传一个值,语义是「允许收尾时才拿 final_answer 兜底」:

# python/beeai_framework/agents/requirement/_runner.py:150
fallback_tool=request.final_answer if request.can_stop else None,

但 backend 侧还有一条补丁,决定最终生效的兜底工具:

# python/beeai_framework/backend/chat.py:459-461
fallback_tool = options.get("fallback_tool")
if fallback_tool is None and isinstance(tool_choice, Tool):
fallback_tool = tool_choice

于是实际结果分三种,「禁止收尾 = 没有兜底」这个说法要收紧:

这一轮的状态tool_choice最终 fallback_tool模型漏写工具名时
允许收尾"required" 或某个工具final_answer当作它想交卷
禁止收尾,允许集剩多个工具"required"None老实报错重试
禁止收尾,允许集只剩一个工具那个工具(Tool 实例)那个工具补壳成那个被强制的工具

第三行不是边角情况:prevent_stop 会把 final_answer 从允许集里摘掉,再叠上「只剩一个工具就把 tool_choice 钉成工具实例」这条收敛规则(utils/_llm.py:144-146:156-158),很容易正好剩一个。

核心语义仍然成立:绝不会兜底到 final_answer 某个需求禁止收尾时,补壳补出来的只可能是那个本来就被强制的工具——容错不损害约束


3.5 出错时的自愈:重试 + 临时引导消息

run() 把整个调用包在 Retryable 里,并注册了一个 on_retry 钩子(chat.py:551-577):

错误类型重试前往消息列表追加什么
ChatModelToolCallError① 模型刚才生成的坏内容(助手消息);② 一条用户消息:错误说明 + Available Tools: a, b, c
EmptyChatModelResponseError一条空的助手消息(轻推模型继续)

追加的消息都打了 {"tempMessage": True} 标记 —— 就是第 1 章讲的、每轮末尾会被 delete_messages_by_meta_key 清掉的那些。自愈引导不会污染对话历史。

重试前还会 await cache_entry.delete(),避免把错误响应缓存下来。

另外两处小修补:

  • _fix_tool_calls(chat.py:683-704):工具调用缺 id 就补一个;args 不是合法 JSON 就用 parse_broken_json 修,甚至处理「JSON 被二次字符串化」的情况(修完还不合法才抛错)。
  • ignore_parallel_tool_calls(chat.py:670-677):模型不听话地一次调了多个工具时,只保留第一个,后面的从消息里摘掉。

3.6 事后校验:_assert_tool_response

即使 provider 声称支持 tool_choice,也可能阳奉阴违。框架在拿到响应后逐条核对(chat.py:864-924):

检查不通过时
关了并行却返回多个工具调用ChatModelError,提示打开 allow_parallel_tool_calls
tool_choice="none" 却调了工具报错
tool_choice 是具体工具却没调 / 调错了工具报错
tool_choice="required" 却没调工具报错
调了一个不在工具表里的工具ChatModelToolCallError,附上可用工具清单

报错信息的写法很少见——它直接给出三种修法的可复制代码(chat.py:945-949):

1. ChatModel.from_name('ollama:llama3.1', tool_choice_support={"none", "auto"})
2. model = OllamaChatModel(...)
model.tool_choice_support = {"none", "auto"}
3. OllamaChatModel.tool_choice_support.discard("required")

框架承认自己的能力表可能过时,并把纠正权交给用户。 这比硬编码一张 provider 能力表要健壮得多。


3.7 提示缓存注入点

第 2 章提过一次,这里补全。RequirementAgent 每轮给出两个缓存断点(_runner.py:153-171):

断点位置为什么
第一个有需求时 index=1,否则 index=0系统提示每轮变,不能当缓存前缀
第二个最后一条非临时消息临时引导消息会被删,不能当缓存边界

第二个断点还带一句 provider 特判:

# python/beeai_framework/agents/requirement/_runner.py:162-164
lambda msg: not msg.meta.get(TEMP_MESSAGE_META_KEY)
# TODO: remove once https://github.com/BerriAI/litellm/issues/17479 is resolved
and (self._llm.provider_id != "amazon_bedrock" or not isinstance(msg, ToolMessage))

Bedrock 上不能把缓存断点放在 ToolMessage 上——带 issue 链接的临时绕行。最后用 ensure_strictly_increasing 去重,防止两个断点落到同一条消息。


3.8 适配层:一个 LiteLLM 打天下

绝大多数 provider 都是 LiteLLMChatModel 的薄壳(adapters/litellm/chat.py:63):

ChatModel(抽象:降级/校验/重试/缓存/事件)


LiteLLMChatModel(_transform_input / _transform_output)

┌──────────┬───────┴───────┬──────────┬─────────┐
Ollama OpenAI Anthropic watsonx Groq …
(改 base_url、补默认模型、声明能力集)

单个适配器有多薄?OllamaChatModel 全文 49 行,干的事就四件:声明 provider_id、默认模型名、把 base_url 补上 /v1、声明 tool_choice_support = set()

不走 LiteLLM 的例外有:transformers(本地推理,自己实现了 tool_choice 处理,见 adapters/transformers/backend/chat.py:222-234)、langchainagentstack

LiteLLM 层还有两处细节:开局设 litellm.drop_params = True(不支持的参数静默丢弃),以及 litellm.disable_cache()(用框架自己的缓存,避免两层缓存打架),两句都在 adapters/litellm/chat.py:80-82


3.9 消息模型

所有 provider 的输入输出都归一到四类消息(backend/message.py):

内容类型
UserMessage文本 / 图片 / 文件
AssistantMessage文本 / 工具调用 / 推理内容(reasoning)
ToolMessage工具结果(带 tool_call_id 配对)
SystemMessage纯文本

两个实用方法:Message.from_chunks / merge 把流式片段拼起来(message.py:127:133);MessageToolCallContent.is_valid() 判断 args 是不是合法 JSON(message.py:100)—— 第 1 章工具执行前的第一道闸就是它(agents/_utils.py:23-27)。


3.10 代码地图

主题文件路径符号名
统一 LLM 抽象python/beeai_framework/backend/chat.pyChatModel
主执行流(重试/缓存/事件)python/beeai_framework/backend/chat.pyChatModel.runChatModel.__run
入参归一与兜底工具补丁python/beeai_framework/backend/chat.py_prepare_model_input
降级判定python/beeai_framework/backend/chat.py_force_tool_call_via_response_format
响应校验python/beeai_framework/backend/chat.py_assert_tool_response_raise_tool_choice_error
坏工具调用修复python/beeai_framework/backend/chat.py_fix_tool_calls
工具联合 schemapython/beeai_framework/backend/utils.pygenerate_tool_union_schemainline_schema_refs
坏 JSON 修复python/beeai_framework/backend/utils.pyparse_broken_json
按名字加载 providerpython/beeai_framework/backend/utils.pyparse_modelload_model
LiteLLM 桥接python/beeai_framework/adapters/litellm/chat.pyLiteLLMChatModel
能力集为空的典型python/beeai_framework/adapters/ollama/backend/chat.pyOllamaChatModel.tool_choice_support
本地推理适配python/beeai_framework/adapters/transformers/backend/chat.pyTransformersChatModel
消息模型python/beeai_framework/backend/message.pyAssistantMessageMessageToolCallContent
重试机制python/beeai_framework/retryable.pyRetryableRetryableConfig