工程实践
自部署 OpenViking 的裂缝清单——14 轮测试与 4 次 install 的学费明细
自部署 agent 记忆服务 OpenViking 的性能修复实录:交互查询从排队 70 分钟修到热查询 p50 0.349 秒。14 轮测试、4 次正式安装换来七个和架构无关的教训——内核参数上限、bash 参数展开陷阱、Go 模板转义、不存在的日志行、mock 保真度与 macOS tar 污染。

零、为什么写这篇
2026-08-14,我给自己的 AI agent 配的记忆服务 OpenViking 慢到没法用了。它自部署在一台 40 核服务器上,Claude Code 和 Codex 每收到一条 prompt,都会先去它那里召回相关记忆再开始干活。它一慢,我所有的 agent 会话都跟着卡——最差的时候,一次召回要等几十秒。
两天之后,热查询 p50 修到了 0.349 秒。中间跑了 14 轮完整测试、4 次正式安装,每一次失败都撞出一个我事先想不到的问题。这篇写的就是这七个问题。架构设计只占一节,因为架构从头到尾没改过方向,反复出事的全是架构之下的那些层。
顺带说清楚难度定位。这台机器上的 OpenViking 带本地模型推理、带一整套发布门禁,和 docker compose up 一把梭的自部署,难度差着量级——这篇的学费单也全部来自差出来的那部分。
崩掉的七个点,全部崩在某一层基础设施的默认行为和我的想象不一致的地方;业务代码一行都没改错。
一、先搞清楚它为什么慢
登上服务器第一件事是看日志。7 天里 560 条慢调用告警,交互查询平均排队 25 分钟,最长一次 70 分钟。但同一份日志里,查询本身的处理时间只有 0.7 秒。
这两个数字能分开,靠的是代码里埋的一对指标:wait_ms 记排队,duration_ms 记处理。没有这对埋点,我大概率会以为是模型推理慢,然后跑去换模型——方向就修反了。
堵住路的是另一类流量。OpenViking 会把每个会话的内容提取成长期记忆,提取时要做大批量的 embedding 向量化,单次最长 168 秒。这些批量任务和交互查询共用同一个 8 核、单并发槽位的 Ollama 进程。一个 168 秒的任务排在前面,后面所有 0.7 秒的查询都得等它。
最诱人的答案也试过了:把这个进程从 8 核加到 30 核。长任务一来,短查询的 p95 还是 50 秒。原因很朴素,核数决定单个任务跑多快,决定不了谁先跑;只要所有任务还挤在同一条队里,最长的那个就垫在所有人前面。
核数决定任务跑多快,队列结构决定你等多久。
二、修法:把两种流量拆成两条队
新版本的改法一句话能说完:跑两个推理进程,一个专门服务交互查询(20 核),一个专门消化批量任务(10 核),两个进程只读共享同一份模型权重,互不抢算力。
分流的活交给一个中间网关。OpenViking 发出 embedding 请求时,服务端代码会给「小的查询类请求」打上一个 HTTP 头;网关认头放行进快道,没有头的、超过 8KB 的、一切拿不准的,统统进慢道。这个设计里我最喜欢的一点是误判方向:分类器出任何问题,后果只是某个请求变慢,快道永远不会被大任务堵死。
队列结构决定尾延迟这件事,我此前在《控制论视角下的 Harness 架构设计》里推演过,这次算在生产环境交了实物作业。修完的验收数字:连续 20 次热查询 p50 0.349 秒、p95 0.369 秒;故意让批量任务全速跑的同时发短查询,p50 也只有 0.97 秒。对比修之前 70 分钟的最坏排队纪录,这个问题关掉了。
三、七个和架构无关的坑
每一条都花了一轮完整测试或一次真实安装才定位出来。
1. 内核不让你传太长的参数
测试脚本长到 132,101 字节,作为一个参数传给 bash -c,报 Argument list too long。Linux 内核对 execve 的单个参数有 128 KiB 的硬上限(MAX_ARG_STRLEN),超一个字节都不行。修法是把脚本写进文件再挂载进容器,别当参数传。
2. bash 的 ${*#pattern} 和你想的不一样
我想从整条命令行删一个前缀,写了 ${*#pattern},结果一个字符都没删。bash 对 $* 做参数展开时是对每个位置参数分别应用,而没有哪个单独的参数能匹配跨参数的模式,于是静默失败。先把 $* 赋给普通变量再操作就正常了。
3. 单引号里的 \\n 走到 Go 模板,输出的是字面的反斜杠加 n
docker inspect 的模板要经过 shell 单引号和 Go 字符串两层解释,\\n 到 Go 手里变成转义反斜杠,输出里没有换行,后面按行解析的逻辑整个落空。这个 bug 在 mock 测试里永远测不出来,只有真 docker 会暴露。
4. 你以为存在的日志行,可能根本不存在
发布验收有一道门,要求数出「每次查询恰好一次 embedding 请求」,数法是 grep 推理引擎日志里的 POST /v1/embeddings。真机上这个数字永远是 0——Ollama 打包的 llama-server 在默认日志级别下压根不打请求行。真实存在的信号是每个任务一行的 processing task,换成数它,这道门就准了。
5. 正则会把任务编号认成 HTTP 状态码
5xx 错误扫描有一条宽松规则,匹配空格包围的 5 开头三位数。llama-server 的任务编号单调递增,跑到 task 503 的那天,扫描器必然误报。扫描前先把任务行排除掉。
6. macOS 的 tar 会污染你的部署树
从 Mac 打包上传到 Linux,解出来的文件属主是我 Mac 上的 UID(501:50),目录权限 0755,还多出一堆 ._ 开头的 AppleDouble 伴生文件;而服务端的身份校验要求 root 属主、0750 树根。chown、chmod、清伴生文件,三件套一个不能少。
7. pkill -f 会杀掉你自己
远程执行 pkill -f "test.sh" 时,承载这条命令的 shell 自己的命令行里就含有这个字符串,于是被自己匹配、自己杀掉,SSH 会话当场断线。把模式拆成 "tes""t.sh" 写,让它匹配得到目标、匹配不到自己。
四、最贵的一课:mock 的保真度
第 4 条值得单独展开,因为它贵——为它付掉了一次完整的生产安装。
测试套件里的 docker 是 mock 的。写 mock 时我需要它返回一些 embedding 请求日志,于是参照 llama.cpp 上游的格式编了 POST /v1/embeddings 这样一行。14 轮测试全绿。然后真机安装,同一个断言数出 0,安装失败,自动回滚。
事后用一次性容器做了验证:Ollama 打包的这个二进制,默认级别下没有任何请求日志。也就是说,测试世界和生产世界各自完全自洽——mock 吐出编造的日志,断言在假日志上通过;真机没有这行日志,断言在真日志上失败。两个世界内部都没有矛盾,矛盾只长在两个世界之间。
修法没有捷径。登上真机,发一个真实请求,把真实的日志格式一字不差抄回 mock,测试从那一刻起测的才是现实。
五、我认了的代价
- embedding 模型留在 4B、2560 维、纯 CPU 推理。换 0.6B 能把算力砍到六分之一,但要全量重建向量索引,发布流程明确禁止在生产上重建。这次不动,OK
- 生成侧的推理深度锁在 medium 档,复杂任务会比 max 档笨一点,换来可预期的交互延迟。我认了
- 客户端召回设了 5 秒硬超时。服务端万一堵死,那一轮 prompt 会静默拿不到记忆注入,agent 少一块上下文照常干活。可观测,可接受
六、它不解决什么
批量负载没有消失,只是被关进了慢道,该做的向量化一次都不会少;会话记忆提取还要过一遍大模型,这段往返延迟原样都在。提交粒度从「整个会话攒到底」改成了「每一万 token 提交一次」,单个批次从 10 MB 级降到几十 KB——石头敲碎了,总重量没变。备份恢复演练和外部监控,这次也完全没碰。
七、三个收获
- 加核救不了排队。30 核对 50 秒的 p95 毫无作用,拆成两条队之后 1 秒以内。花钱之前先分清瓶颈是算力还是队列结构
- 调度上的异步挡不住资源上的争抢。后台任务只要和你的查询共用同一个推理引擎,它就在你的关键路径上
- 测试全绿只证明代码和 mock 一致。mock 和现实一致不一致,只能上真机花钱验
八、未关闭的循环
- 存量大会话的记忆清欠还在慢道上消化,只看不动(monitoring)
- 0.6B 模型迁移挂起,重访条件是 CPU 预算吃紧,或哪天出现能重建索引的维护窗口(proposed)
- 备份恢复演练、外部监控、密钥迁进 1Password,三个口子还开着(70% 把握月内关掉)