Atlas如何「嗅探」Agent使用的模型?atlas-acp的model_sniff实战拆解
【免费下载链接】atlasSource control for agents. Use multiple coding agents, track their changes and query them in one place项目地址: https://gitcode.com/GitHub_Trending/atlas115/atlas
Atlas 是一款「面向 AI 编程 Agent 的源代码管理工具」:你可以在同一处同时使用多个 Coding Agent(如 Claude Code、Codex、OpenCode 等),统一追踪它们对代码库的改动并随时查询。它的 ACP 客户端核心位于 crates/atlas-acp/,其中model_sniff模块解决了一个很隐蔽的问题——如何准确「嗅探」出每个 Agent 当前使用的模型列表,让 UI 上的模型选择器永远有货可显示。
问题背景:为什么模型列表会「消失」
Atlas 通过ACP(Agent Client Protocol)与各个 Agent 子进程通信:按行分隔的 JSON-RPC 消息走 stdin/stdout。模型信息最初藏在session/new(新建会话)和session/load(恢复会话)的响应里,形如:
{ "currentModelId": "opencode/grok-code", "availableModels": [ {"modelId": "...", "name": "Grok Code"} ] }但 ACP v1 规范发生了漂移:NewSessionResponse结构体悄悄删除了顶层models字段,模型选择被挪进了configOptions。新版 Agent(如 Kilo)没问题——crates/atlas-acp/src/schema.rs 里的model_blob_from_config_options会把configOptions归一化成统一的模型列表格式。
可问题在于:OpenCode 1.3.x、Cursor 等已发布的 Agent 仍在使用旧方言,坚持返回顶层models对象。类型化的反序列化器会把这个字段直接丢弃——结果就是模型选择器渲染不出来,用户看到的是"这个 Agent 不能切换模型"。
💡 详见 crates/atlas-acp/src/model_sniff.rs 文件头部的注释,完整记录了这段设计动机。
核心思路:不 Fork 协议,在「线路」上偷听
Atlas 没有去分叉 ACP schema,而是采用了更轻量的方案——在原始 JSON-RPC 线路上挂钩子:
- 挂调试钩子:ACP 的
AcpAgent::with_debug提供逐行回调,能看到写入 stdin 和读自 stdout 的每一条原始消息。在 crates/atlas-acp/src/driver.rs 中,Atlas 把每个方向的行分流给ModelSniffer的两个观察方法。 - 记住请求 ID:
observe_outgoing监听发出的session/new/session/load请求,记录其 JSON-RPCid(数字和字符串统一转成字符串作键)。 - 匹配响应抓取:
observe_incoming收到响应时按 id 匹配,从result.models里把整个模型 blob 摘出来,按 session id 暂存。 - 取用即销毁:注册表在组装
NewSessionInfo时调用take(session_id)取走 blob——读取即消费,避免重复填充。
整个实现只有 crates/atlas-acp/src/model_sniff.rs 一个文件,核心结构体是ModelSniffer:
| 字段 | 作用 |
|---|---|
pending | 在途的session/new(None)/session/load(会话 ID)请求表 |
captured | 已抓到的旧方言modelsblob,按会话 ID 索引,等待注册表取走 |
工程细节:四个容易踩的坑都被填上了
model_sniff的代码值得细读,因为它把协议嗅探里所有边缘情况都处理干净了:
- 内存有界:
pending超过 32 条、captured超过 64 条时直接清空。一个卡死不回复的 Agent 不会让映射表无限泄漏。 session/load响应不带会话 ID:session/new的响应会返回新生成的 id,但session/load只回显模型列表。所以嗅探器在发请求时就把params.sessionId存下来,响应没带 id 时兜底使用(见 model_sniff.rs 的注释)。- 无关流量免疫:无 id 的通知(如
session/prompt)、未跟踪的 id、错误响应、甚至 Agent 启动时打在 stdout 上的 banner 文本(如opencode v1.3.15)都会被安全忽略,不会 panic。 - 读取即消费:
take()用remove实现,同一 blob 不会被折叠进NewSessionInfo两次。
测试代码同样精彩——model_sniff.rs 里用 OpenCode 1.3.x 的真实响应报文做了逐字复刻,4 个测试分别覆盖:常规抓取、session/load兜底 ID、无关流量与错误响应。想跑一遍完整链路,可以直接用示例工程 crates/atlas-acp/examples/smoke.rs,它同样演示了with_debug钩子打印每条进出消息的用法。
嗅探结果如何送达前端
抓到 blob 只是上半场,后半段是「接力」:
- 注册表兜底:crates/atlas-acp/src/registry.rs 的
new_session里,若类型化响应没给出模型(info.models.is_none()),就从model_sniffer取嗅探到的 blob 补上——旧方言 Agent 的模型选择器因此得以正常渲染。 - 前端缓存加速:模型列表本质上是静态的(只有 Agent 升级才会变),但首次获取需要等待进程启动 + 握手(约 3–4 秒)。src/features/chat/lib/acp-models-cache.ts 把每次确认的列表持久化到 localStorage,切换 Agent 时立即乐观填充模型选择器。
- 后台预热:src/features/chat/lib/warm-acp-models.ts 会在后台为其他用过的 Agent 开一个"一次性会话"刷新缓存(带 7 天 TTL,避免每次启动都白开会话),让这个"嗅探 + 缓存"的闭环对用户完全无感。
小结:一段不到 130 行的协议考古
model_sniff是"在协议规范与现实之间打补丁"的优秀范例:
- 🎯不动 schema:不 fork 协议,只在原始线路上旁路监听,侵入性最小;
- 🛡️防御式编程:有界缓存、ID 归一化、ID 兜底、脏数据免疫,全部就位;
- 🔗端到端闭环:从 Rust 侧嗅探 → 注册表兜底 → 前端缓存/预热,UI 体验始终"秒出模型列表"。
对想维护多 Agent 工具链的开发者来说,这套"wire tap"(线路窃听)手法通用性很强:凡是类型化反序列化会丢字段、而你又不想受制于某个库版本的场景,都可以照此办理。核心源码就在 crates/atlas-acp/src/model_sniff.rs,配合 crates/atlas-acp/src/driver.rs 的钩子接线一起读,半天就能吃透。
【免费下载链接】atlasSource control for agents. Use multiple coding agents, track their changes and query them in one place项目地址: https://gitcode.com/GitHub_Trending/atlas115/atlas
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考