Learn Alma:Tool Model 和 Chat Model 的分离,以及 Web Fetch 和 Web Search 的编排
在开发仓库 XnneHangLab/XnneHangLab 的过程中,一直想让 pet_mode 下的 Live2D 角色陪伴写代码:盯着进度、指责摸鱼、鞭策按时测试与提交——最好还能“理解当前在做什么”,例如识别当天改动、检测测试是否通过、提醒未完成事项。
目标听起来偏娱乐,但拆开后其实是一套完整的 Agent 设计问题:工具如何接入、联网如何编排、记忆如何持续、模型如何切换且成本可控。
近期开始研究 Alma。它的架构选择提供了很强的启发:之前踌躇不前的难点,并非“解不开”,而是“没拆开”。下面把目前的思路整理成一篇可落地的笔记(并同步我在 XnneHangLab 里最近几次 PR 的落地进展)。
1. Tool Model 和 Chat Model 分离:不仅隔离上下文,也隔离模型职责
过去我的直觉一直是:工具调用的中间步骤(抓网页、读文件、跑命令)产生的上下文应该和正常对话消息做隔离管理,避免噪声进入主对话上下文。
而 Alma 的启发更进一步:不仅隔离上下文,还把 模型本身 做了职责隔离。
1.1 为什么要分两层模型( Tool && Chat)?
一种清晰的分工方式如下:
- Tool Model:更快、更便宜、更擅长结构化输出(function/tool call),负责
判断是否需要 tool call, 以及选择需要的 tool function
- Chat Model:更贵、更强、更擅长推理与表达,负责
整理,表达,比较考验多文档和长文本理解能力。
- 成本:gpt-4.1-mini 就能在合理的上下文下判断出来需要的 tool fn,和 gpt-5.2-chat 有着 10 倍以上的价格差距,但是效果差距却不那么大,甚至短上下文差别不大。
- 延迟:thinking 的模型自带推理链,会让模型回复变慢,而这意味着 tool 每步都变慢,也就是和卡死差不多了,毫无使用体验。
- 可靠性:比起每次换一个 chat model 担心这个没有 function calling 或者能力比较差,不如选取一个共同认可的好的 tool model。而 chat model 就看喜好和预算以及需求了。
1.2 实践补丁:system prompt 最好分开管理
chat_system_prompt 里通常都是一些人格方面的定义比如我喜欢调成这样:
以下是你(伊蕾娜)的设定(英文名:Elaina)的角色设定:
- 身份背景:15岁就取得了魔法使中的最高位「魔女」称号,魔女名为「灰之魔女」。
- 外貌特征:美少女,留着灰白色的长发,披着黑色长袍,戴着魔女帽,胸口别有魔女之证的「星形胸饰」。
- 性格特点:
- 你知道自己很可爱,而且你喜欢别人夸你可爱,并且被夸时一点不会脸红。
- 说话辛辣毒舌,但内心温柔,是个老好人。
- 自信满满,常有自恋的发言与行为,感染力十足。
- 不擅长下雨天和与有百合兴趣的女孩子相处。
- 有少量的S性格
- 兴趣喜好:
- 不喜欢吃菇类食物,最爱吃面包,擅长做炖菜。
- 对猫过敏,但在某次经历后过敏体质消失。而且你后来很喜欢猫。
你不会直率地透露出你的性格特征和真实想法。不要在对我的回复中直接引用以上的内容。
...
这里只包含了一小部分,实际上我还写了很多,很多,快把她的一辈子写进去了...拧巴的复杂的混乱的 system prompt 它导致我的 tool model 性能下降了不只一半,自从我换了一个简约的 system prompt 后 tool call 几乎再也没失败过了。
2. 配置演进:从 “provider 绑定单模型” 到 “provider=连接信息 / model=选择项”
现有 lab.toml 的 provider 配置常见形态是把 api_key/base_url/model_name 绑定在一起,例如:
[agent.llm.openai]
llm_api_key = ""
llm_base_url = "https://api.openai.com/v1"
llm_model_name = "gpt-4o"
[agent.llm.gemini]
llm_api_key = ""
llm_base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
llm_model_name = "gemini-2.5-flash"我原本通过 provider 锁定用户使用的 model,比如用户锁定 openai,我就能通过 agent.llm.openai 得到它的 model_name,base_url, api_key。
问题在我拆分 tool chat model 后暴露得也很直接:一个 provider 只能固定一个 model_name。
当出现这种组合需求时就无法表达:
- Tool Model:OpenAI 的
gpt-4o-mini - Chat Model:OpenAI 的
gpt-5.2-thinking
另外在实际实践中我添加了 vision model 用于 chat model 的 vision fallback。当 chat model 是 text-only 时调用。
2.1 Provider 与 Model Name 选择解耦
更推荐的结构是:
[llm.providers.openai]
api_key = ""
base_url = "https://api.openai.com/v1"
api_format = "chat_completions"
[llm.providers.gemini]
api_key = ""
base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
api_format = "chat_completions"
[agent.models.tool]
provider = "openai"
model = "gpt-4o-mini"
[agent.models.chat]
provider = "openai"
model = "gpt-5.2-thinking"
supports_vision = true # 用来判断要不要 fallback
[agent.models.vision]
provider = "openai"
model = "gpt-5.1-2025-11-13"
[agent]
enable_tools = true
enable_mcp = true关键点:
llm.providers.*只负责连接信息(base_url / api_key / api_format)agent.models.*只负责“当前角色使用哪个 provider 的哪个 model”- tool/chat/vision 的选择互不绑定,可同源也可异源
api_format是扩展位:即使当前只支持chat_completions,也为未来兼容其他格式预留空间
最后用户填下 provider + model_name ,我就能补全锁定所有需要的内容。
2.2. 静态类型(Literal)能否约束 model_name?大概率不必强求
我写了一个 fetch_model_list 来自动 fetch 那些填写了 api key 的 provider 的 model_list。
后面我考虑 model_name 能不能用静态类型强约束和校验,比如说, model_name 必须在 provider 所提供的 model_list 下。进而实现静态校验。
但问题是,fetch 得到的是动态类型,即便缓存到文件也无法作为类似于 Literal 来进行约束,进而放弃,保持 str。
3. 工具系统落地:tool_message Tool Model 应该共享哪些上下文, tool_loop 并行 && 链式 tool call
这块的所有内容都针对 Tool Model 执行。
3.1 流式 or 非流式
首先确定的是, Tool Model 的 OpenAI 的调用是非流式的。
我在 Chat Model 中,因为要做 TTS,为了首句延迟尽量低,所以我就采用了流式回复,并且动态处理 return text 的方式。
但是对于 Tool Model 流式只会让回复变长,信息聚合难度变高。一直保持非流式才是 Tool Model 的好做法。
3.2 Tool Model 应该和 Chat Model 共享哪些消息。
Tool Model 是否应该和 Chat Model 共享所有消息,这一想就是否定的,因为 Tool Model 的高频调用,如果 Chat Model 的历史上下文长达几万 token 不仅开销大而且速度慢。
所以 Tool Model 的上下文一定是简短的,但是简短不意味着无状态(system+user_input=output)。
它还是需要在需要的时候扩展一些上下文来应对某些情况。比如:
你能看到我现在桌面环境吗?描述一下。
[Tool] call screen_shoot
...
我换环境了,你再描述一次。 # 如果没有上下文, Tool Model 完全不知道要描述的是什么东西,就不会二次调用了。它应该动态扩展 5~15 条最近的上下文。
如何判定动态扩展
至于扩展规则(什么时候该扩展),这个并不是交给模型自己来决策的,如果交给模型自己实在是太吃操作了,能力不一样的模型差距越大,表现和结果差距越大,并不是我们想要的效果,我们希望它尽可能稳定。
于是乎采用了"语义"+"评分器" 类似这样一个简单的案例:
_CONTEXT_CUES = re.compile(
r"(继续|刚才|上一个|那个|同样|照之前|按之前|再来一次|你说的|第[二三四五六七八九十]个|前面|如上|同上|this|that|it)",
re.IGNORECASE,
)
_CHOICE_CUES = re.compile(r"(第[0-9一二三四五六七八九十]+个|\b[0-9]+\b|\b[A-F]\b)", re.IGNORECASE)
_CONFIRM_CUES = re.compile(r"^(对|不是|可以|行|好|嗯|OK|okay|yes|no|y|n)\b", re.IGNORECASE)
_PRONOUN_START = re.compile(r"^(它|他|她|这|那|this|that|it)\b", re.IGNORECASE)
_HAS_URL = re.compile(r"https?://\S+", re.IGNORECASE)
_HAS_PATH = re.compile(r"(\.?/[\w\-/\.]+|\w+\.(md|txt|json|toml|yaml|yml|py)\b)", re.IGNORECASE)
@dataclass(frozen=True)
class ContextDecision:
dependent: bool
score: int
reasons: list[str]
def is_context_dependent(user_text: str) -> ContextDecision:
t = (user_text or "").strip()
if not t:
return ContextDecision(False, 0, ["empty"])
score = 0
reasons: list[str] = []
# 1) very short
if len(t) <= 6:
score += 3
reasons.append("very_short<=6")
# 2) reference cues
if _CONTEXT_CUES.search(t):
score += 2
reasons.append("context_cue")
# 3) choice / index
if _CHOICE_CUES.search(t):
score += 2
reasons.append("choice_cue")
# 4) confirm-like reply
if _CONFIRM_CUES.search(t):
score += 2
reasons.append("confirm_reply")
# 5) pronoun start
if _PRONOUN_START.search(t):
score += 1
reasons.append("pronoun_start")
# 6) if user already provides concrete target, dependency is lower
if _HAS_URL.search(t) or _HAS_PATH.search(t):
score -= 2
reasons.append("has_explicit_target")
dependent = score >= 3
return ContextDecision(dependent, score, reasons)但是这个方法确实是有效的。但是我能料想到规则变得更复杂后,代码也更难管理和扩展。
所以其实这块感觉可以采用 Embedding 模型特征分类来做。我不想把它作为一个 mcp_tool,也许可以简单地把它作为一个 tool.prompt,告诉大模型,如果用户这句话里面有需要依赖以前的上下文的迹象,就 return True else False。
这实际上有点反逻辑,因为这个函数实际上用来管理 tool model 要不要上下文,但是它却去问没有上下文的 tool model 说,你觉得这句话要不要上下文,然后就会陷入我说的那个场景,不同的模型,不同的理解,效果完全不同,难以控制,不一致。
有点长,所以来个总结
- Tool Model 默认只接收:
system(tool_router_prompt)TOOL_ROUTER_STATE(pinned JSON)last user message
- 当检测到上下文依赖(短回复/指代词等)时,扩展注入最近窗口(通常 8~15 条,包含上一条 assistant 以支持“第二个/对/不是”)
ConversationState在每轮用户输入与工具返回后更新refs/slots:
如last_url_ok/last_file/last_image_ref,用于指代消解与缺参补全- 引入 resolved refs(可选):当用户说“上一个链接/那张截图/那个文件”时,直接注入已解析锚点,避免 tool model 猜测与漂移
3.3 Tool Loop 并行和链式
本质上是一个 loop 循环检查有没有 tool call,每次检查会加上上一次 tool call 的 tool_trace。
类似这样:
shell
q: 你截图看看我桌面上的那个图像,和魔女伊蕾娜像不像
step1: Tool Fn = [vision__screen_shot,tool__web_search]
step2: Tool Fn = [tool__web_fetch]
step1 中调用了截图和 web_search,这个由 user prompt 触发。
然后 step2 会加入 Tool Trace 判断要不要接着调用, tool trace 是类似这样的集合:
PS D:\tmp\XnneHangLab> uv run .\tmp.py
tool_trace is a list of the following items:
{
"server": "timeemi",
"name": "get_date_and_time",
"args": {},
"raw_result": {
"datetime": "2026-02-05 13:53:10"
},比如 Tool Model 从 step1 里得到了 elaina 的图像的网址,就会进一步地 fetch 这个地址尝试得到 elaina 的图像。
至于模型最后会不会比较还得看提示词。
我们会把所有的 Tool Trace 放到一个 Tool Summary 中并且和 user prompt 一起发送给模型(以 role = "user" 发送),可能还包含了 Vision Summary。
# prompt 主体(四段里前两段用这个做基础)----
base_prompt = "\n\n###\n\n".join(
[
f"[Task / User Prompt]\n{user_input_text}",
f"[Tool Call Summary]\n{tools_summary_str}",
]
)3.5 tool call result 里的 base64 炸 text,只能传 image url (base64 发送)
另一个很现实的问题:截图/图片一旦把 base64 直接塞回 tool result 文本,你的上下文窗口基本就报废了。
return {
"role": "user",
"content": [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
],
}它让我在 execu_tool 后得再额外检测 ImageResult 然后把它取出缓存,再存索引到 state,然后这次回复和下次需要 last image ref 的适合根据索引从缓存里取出来。
这块逻辑有一丢丢的割裂,不是很好管理。
3.6 失败后引导
retry_hint。
它发生了 Tool Model run_tool_loop 时发生错误发送的,这个错误可能是,输入 args 或者 得到 result 校验失败。
它会立刻向 Tool Model 发送一个 retry 的指令类似这样:
TOOL_RETRY_HINTS: dict[str, str] = {
"vision__screen_shot": (
"Retry once: call vision__screen_shot with NO arguments.\n"
"If it still fails: tell the user you cannot access their desktop, "
"and ask them to describe what they see or provide a screenshot image."
),
"tool__web_search": (
"Retry once with a simpler query or a different provider.\n"
"If you need details: pick 1 URL from results and call tool__web_fetch."
),
"tool__web_fetch": (
"Retry once with a larger max_chars (e.g., 12000~20000) "
"or fetch a more specific URL/section.\n"
"If blocked by robots or 4xx/5xx: report the status and ask user for another URL."
),
"tool__read_file": (
"Retry once with a correct path (relative to project root) "
"or adjust start_line/end_line.\n"
"If file not found: ask user for the correct file path."
),
"timeemi__get_date_and_time": "Retry once with NO arguments.",
"timeemi__roll_dice": "Retry once with a reasonable n_dice (1~100).",
"timeemi__roll_dice_by_current_time": (
"Retry once with unit in ['hour','minute','second'].\n"
"If prompt rendering fails: verify the MCP prompt name exists on the server."
),
}
DEFAULT_RETRY_HINT = (
"Retry once with the same arguments.\n"
"If it still fails: report the error briefly and ask the user for missing info."
)4 Vision fallback:让 text-only 模型具有多模态能力(多模型协同)
学着 tool model 和 chat model 的拆分,我拆分出了一个 vision model,用来适应:
当 chat model 不支持 vision(supports_vision=false),但用户输入里包含图片时:
- 若检测到图像输入且
chat_model.supports_vision=false且配置了vision_model:- 由
vision_model对图像生成短的结构化 summary(不做长文/不做最终口语化) - 将 summary 与
tool_trace一起交给chat_model输出最终回复
- 由
- vision model 的上下文可配置:默认与 chat model 同步;若追求成本,也可以只注入
system_prompt + 图像 + 必要提示/工具摘要,这样反而更多适合更适配。
5. 我们自己的 Web 状态机(Web Search 和 Web Fetch)
无论从成本还是技术上我都很难实现 chatgpt-5.2-thinking 那样长达十几分钟的 search 和 fetch。
但是基本的 search 和 fetch 我还是希望有的。
两个 tool 由模型自己判断调用,不过如果一个 step 里两个被同时 call 了。
那么选取 Web Search 独占当前 step ,然后把 search 到的 url 移交下一个 step 进行 web fetch。
我是希望避免第一步在没得到所有相关链接时就匆匆 fetch,那样可能会少掉 search 得到的信息(模型觉得第一步 fetch 的已经够了),或者需要两次 fetch 一次 search,那样得到的结果会被分成在两个 tool trace 里。我的思路是先汇总再 fetch。它在我的测试案例里表现得很不错。
6. 结语:把“陪写 + 鞭策”变成可实现的工程路线
经过拆解后我需要做的有:
- ✅ 可替换模型的 Agent 架构(tool/chat 分层,并补上 vision fallback)
- ✅ 可控的工具系统(tool_server + ToolRegistry + retry_hint + 图片正确通道)
- ✅ 有预算的 Web 状态机(WS/WF 编排 + “WS 单步独占”调度规则)
- ✅ pinned ConversationState + 动态上下文注入(低上下文也能做指代消解)
- 后续仍可继续拆解:记忆系统、repo 工具(git diff / tests / lint)、人格策略、以及 pet_mode 的鞭策规则