上一篇 我写过 ace-wrapper:把 ACE(Augment Context Engine)的 filesystem context search 包成一个 shell 命令,让 agent 在关键词不明确时先走语义检索,再决定读哪些文件。
结果 ACE 开始不稳定了。
API key 换着花样失效,免费额度越来越难薅,几个中转服务也一个个扑街。
这也怪不了谁,毕竟本来就是 preview 功能。问题在于,编码助手的工作流已经长在语义检索上了:一天几十次 ace 调用,少了它,agent 又回到盲猜关键词的老路。
所以我换了个办法:
这次我没走第三方 API,而是直接逆向 Windsurf 的 SWE-grep 协议——也就是 Codex CLI 和 Windsurf IDE 自己在用的那个语义搜索后端——同时在本地加了一层 Semble 缓存做降级。
结构跟 ace-wrapper 最大的区别 #
ace-wrapper 是纯远程调用:本地只传参数,一切靠 ACE 服务。
fast-context 则是本地和远端一起上。
flowchart TB
subgraph Input
Q[User query]
end
subgraph Local
S[Semble local prefetch
cached index + chunk search]
A[Lexical anchors
filename / path / literal hits]
R["Repo map
(auto-shrink when too large)"]
end
subgraph Remote
WS[Windsurf SWE-grep
agentic verify + expand]
end
subgraph Output
O["Candidate files
line ranges
follow-up terms
(or local chunks when remote fails)"]
end
Q --> S
Q --> A
Q --> R
S --> WS
A --> WS
R --> WS
WS -- success --> O
WS -- auth / rate-limit / timeout --> O
S -- fallback path --> O
流程变成:
- 先在本地跑 Semble——缓存的索引+ chunk 搜索,毫秒级返回命中
- 收集本地 lexical anchors——精确的文件名、路径片段、内容中的字面量匹配
- 生成 repo map——代码树结构,太大了就自动压缩
- 把这三样打包发给 Windsurf——Semble 的 chunk 候选当提示,lexical anchors 当锚点,repo map 给路径上下文
- Windsurf 用 rg/readfile/tree/ls/glob 验证和扩展——agent 层的工具调用循环
- 远端走不通时,直接返回本地 Semble 结果——不空手,不卡住
这个“不空手”其实很关键。ace-wrapper 依赖 ACE 时,服务一挂,那一轮搜索就没了。现在远端断了,本地缓存至少还能给出 chunk 级别的候选,质量差一点,但工作流不会直接卡死。
逆向 SWE-grep 的过程 #
Windsurf 的 SWE-grep 走的是 Connect-RPC + Protobuf,和典型的 REST API 完全不是一回事。
最麻烦的是 Connect 协议的帧编码。每个 RPC 帧前有个 5 字节头(1 字节 flag + 4 字节大端长度),请求和响应都这么包。协议本身还要求先发一条 Connect-Connect 帧,然后才是实际数据。
Protobuf 这边更烦。Windsurf 用的是自定义 proto schema,公开定义找不到。核心数据结构的 field numbers 只能从抓包或已知的 Wireshark 解密配置里猜——比如调用链 {1: name, 2: args, 3: id}、变量定义 {1: name, 2: type, 3: value}。猜错就整个请求失败,而且没有什么友好的报错。
整个编码器大概这样(ProtobufEncoder):
class ProtobufEncoder:
"""手动 protobuf 编码器,完全匹配 Windsurf 的请求格式。"""
def __init__(self) -> None:
self.buf = bytearray()
def _varint(self, value: int) -> bytes:
parts: list[int] = []
while value > 0x7F:
parts.append((value & 0x7F) | 0x80)
value >>= 7
parts.append(value & 0x7F)
return bytes(parts)
def _tag(self, field: int, wire: int) -> bytes:
return self._varint((field << 3) | wire)反过来,接 Windsurf 返回的流式响应也得自己解码——拆帧、读数据、找流结束标志——最后才能拿到语义结果。比调 REST API 麻烦得多,但好处也明显:不需要任何中间服务,直接打 Windsurf 后端。
本地 Semble 缓存为什么管用 #
当初加 Semble 之前,我其实犹豫过:本地建一份索引,会不会是多此一举?
后来 benchmark 一跑,这事就没悬念了。
我拿 40 条标注查询在两个仓库上跑了对比(fastapi 和 axios),结果是:
| Backend | NDCG@10 | Recall@10 | Top-1 | Batch p50 |
|---|---|---|---|---|
| local(仅 Semble) | 0.854 | 0.946 | 0.775 | 30 ms |
| remote(仅 Windsurf) | 0.453 | 0.467 | 0.450 | 24.4 s |
| hybrid(Semble + Windsurf) | 0.890 | 0.979 | 0.825 | 28.3 s |
本地 Semble 自己的召回率已经 94.6%,p50 只有 30 毫秒。Windsurf 单独跑反而有点拉——成功率只有 52.5%,剩下的不是被限流,就是报 resource_exhausted。
hybrid 模式则是把 Windsurf 放到 Semble 结果后面做验证和扩展,NDCG@10 涨到 0.890,召回率升到 97.9%。
这个结果让我确定了两件事:
- 本地缓存不是备胎,是第一道防线。它在 30 毫秒内能搞定绝大部分常见搜索,远端挂了就是降级路径,而不是直接废掉。
- Windsurf 的价值在验证,不在首轮搜索。直接让它从头搜,容易超时或被限流;给它 Semble 的 chunk 候选和精确的关键词锚点后,它只需要在已知问题上做确认,成功率明显高很多。
凭据处理也比之前复杂了 #
ace-wrapper 只需要一个 API key。fast-context 拿的是 Windsurf 的 session token,存在本地的 state.vscdb(SQLite 数据库)。
提取逻辑在 extract_key.py:
从 state.vscdb 的 ItemTable 里查 key 为 'windsurf.api_key' 的行
→ 如果有,直接返回
→ 如果没查到,再查 key 包含 'devin-session-token' 的行
→ 两种格式都能用
→ 也可以通过 WINDSURF_API_KEY 环境变量覆盖为什么两种格式都要支持?因为 Windsurf 自己就在变。前期是标准 API key,后来改成了 devin-session-token$... 这种 session 风格的凭据。不跟着变,用户升级 IDE 后工具就废了。
现在的工作流 #
ace-wrapper 阶段,我的 AGENTS.md 长这样:
用 ace 做语义检索找候选文件 → 读文件 → 用 rg 确认精确证据现在改成了:
用 fast-context search(默认 hybrid)找候选文件 + 行号范围
如果 hybrid 超时或无结果,试试 fast-context local-search
如果有 chunk 候选想看相关代码,用 fast-context find-related
读完文件后用 rg/ast-grep 确认精确证据路径是多了几条,但每条都知道失败后该往哪退。
远程也搭了一套模型 fallback 链:
- 默认用
MODEL_SWE_1_6_FAST - 遇到
resource_exhausted或限流,自动降级到MODEL_SWE_1_5 - 还能通过
WS_FALLBACK_MODELS自定义 fallback 顺序
一些数字 #
用 fair runner(completion-based cooldown, 40 queries)重新跑 benchmark 后,几个指标更能说明问题:
- hybrid 模式非空输出率 100%——40 条查询全部返回了有效结果
- remote 模式非空输出率只有 50%——剩下一半要么超时要么被限流
- local 模式零失败——100% 非空,p50 延迟 30 ms
这意味着,如果纯靠远程语义搜索,高峰期可能一半查询直接没响应。hybrid 模式有本地 Semble 打底后,最差也会给出本地 chunk,而不是空结果。
这次暂时不想再推翻重来的几个点 #
这次重写有几个架构选择,目前看来是对的:
- 始终保持降级路径。任何远程依赖都得有本地回退。ACE 那次已经吃过亏了。
- 纯 Python 更好维护。ace-wrapper 也是 Python,但这次代码量从几百行涨到了两千多行——有 protobuf 编码器、Connect 帧协议、Semble 适配层、benchmark runner——结构清楚比语言选型重要得多。Python 只是我最顺手。
- Benchmark 要跟代码一起放。benchmarks/ 里的 40 条标注查询和 runner,跑一次就能看到各个 backend 的真实差异。没有数据支撑的优化决策,基本靠猜。
- 凭据提取要自动适配。
devin-session-token这种变化是预料之外的,但代码结构上留了扩展点——查不到 key 就换个 pattern 再查一次,不用改主流程。
最后 #
ace-wrapper 到现在我还在用——ACE 偶尔又能通了。但我已经不想把工作流绑死在它上面。
fast-context 的核心思路其实很简单:语义搜索先靠本地缓存托底,远端负责验证和补充。纯远程方案一旦上游抽风,就容易断绳。
如果你也踩过这个坑,代码在这:ferstar/fast-context