1. 为什么我要给 Claude Code 手搓一个本地后端
事情的起因很直接:我日常用 Claude Code 做多 Agent 协作开发,一个主 Agent 负责拆任务,几个子 Agent 分别写前端、写后端、跑测试。跑单任务的时候一切正常,但只要三个以上 Agent 同时开工,问题就来了——响应开始排队,上下文一长就卡,最要命的是几个 Agent 抢同一份 KV 缓存,前脚刚算完的注意力键值,后脚就被另一个 Agent 的请求冲掉了,等于白算。
这个现象在圈子里其实很普遍。Claude Code 本身是个客户端工具,它默认把请求发到远端服务,你没法控制并发调度,也没法干预缓存复用。多 Agent 场景下,每个 Agent 都是独立的会话,各自维护自己的上下文,但底层如果共享同一套推理资源,就会互相踩踏。我试过简单地加并发限制,结果只是把"抢"变成了"等",吞吐量并没有上去。
所以我决定换个思路:既然远端调度我管不了,那就在本地自己搭一个后端,把 Claude Code 的请求接管过来,由我来决定谁先算、谁复用谁的缓存、算力怎么分。这个后端要满足几个硬性条件:第一,得兼容 Anthropic Messages API 的请求格式,这样 Claude Code 不用改任何配置就能指过来;第二,底层推理引擎要能让我手动控制 KV 缓存的生命周期;第三,要有一个调度层,能在多个 Agent 请求之间做优先级和缓存亲和性判断。
选 llama.cpp 作为推理引擎是权衡后的结果。它的 KV 缓存管理相对透明,支持通过 slot 机制复用上下文,而且对 Anthropic 风格的请求做一层适配并不复杂。整个方案的核心不是"跑得动模型",而是"让多个 Agent 跑得动还不互相拖累"。这篇文章我会把设计思路、关键实现、踩过的坑和排查方法完整讲一遍,适合已经在用 Claude Code 做多 Agent 开发、并且对本地推理有一定了解的人参考。如果你只是单 Agent 用用,那这套东西对你来说属于过度设计,可以先收藏着。
2. 整体架构设计与核心思路拆解
2.1 三层结构:接入层、调度层、推理层
我把整个后端拆成三层,每层职责单一,方便单独调试。
接入层负责伪装成 Anthropic 的服务端。Claude Code 发出来的请求走的是/v1/messages这个路径,请求体里有model、messages、max_tokens、system、stream这些字段。接入层要做的就是把这套格式翻译成 llama.cpp 能吃的格式,同时把流式响应再翻译回 Anthropic 的 SSE 事件格式。这一层最关键的是不能有状态,所有状态都往下沉,否则多 Agent 并发时接入层自己就成了瓶颈。
调度层是整个方案的大脑。它维护一个请求队列,每个请求进来时带上 Agent 的身份标识(我从请求头的自定义字段里塞进去的),然后调度器根据三个维度决定处理顺序:Agent 的优先级、请求的上下文长度、以及这个请求能否命中已有的 KV 缓存。缓存亲和性是这里的核心——如果某个 Agent 的对话历史和某个已缓存的 slot 高度重合,就优先把它调度到那个 slot 上,省掉重复的 prefill 计算。
推理层就是 llama.cpp 的 server 实例。我用的不是它默认的 HTTP server,而是自己封装了一层,因为默认 server 的 slot 分配策略是"谁先来谁占",不满足我的缓存亲和需求。我通过它的底层接口手动管理 slot 的分配和释放,让调度层的决策能真正落地。
2.2 为什么不用现成的方案
有人会问,vLLM 不是自带 PagedAttention 和连续批处理吗,为什么不用它。我实际测过,vLLM 的调度确实强,但它的 KV 缓存是按 block 管理的,对"整段上下文复用"这种场景反而不够灵活。我的多 Agent 场景里,每个 Agent 的 system prompt 和早期对话是高度重复的,我需要的是把一整段前缀缓存住,让多个 Agent 共享,而不是按固定大小的 block 去拼。llama.cpp 的 slot 机制天然适合这种"整段复用"。
另一个原因是部署成本。vLLM 对显存的要求更高,我本地是一张消费级卡,跑量化模型本来就紧巴巴的,再叠一层复杂的调度开销,留给 KV 缓存的空间就不够了。llama.cpp 的量化支持和显存占用控制更符合我的硬件条件。
2.3 缓存亲和性的判断逻辑
这是整个设计里最需要想清楚的部分。我的做法是给每个 Agent 的对话历史算一个"前缀指纹"——把 system prompt 加上前 N 轮对话拼起来,取哈希。调度器维护一张表,记录每个 slot 当前缓存的前缀指纹。新请求进来时,先算它的指纹,然后去表里查有没有匹配的 slot。
匹配分三种情况。完全匹配,说明这个 Agent 之前就在这个 slot 上跑过,直接复用,prefill 成本几乎为零。前缀匹配,说明新请求是在旧上下文基础上追加了内容,可以复用旧缓存,只算新增部分的 prefill。完全不匹配,那就得找空闲 slot 或者淘汰最久未使用的 slot,从头算。
提示:前缀指纹的 N 不要设太大。我一开始取了全部历史,结果指纹变化太频繁,几乎命中不了。后来改成 system prompt 加最近两轮,命中率从不到 20% 提到了 70% 以上。
3. 核心细节解析与实操要点
3.1 Anthropic Messages API 的适配细节
Claude Code 发出来的请求和 OpenAI 格式有几个关键差异,适配时容易踩坑。
第一是system字段的位置。Anthropic 把 system prompt 放在请求体的顶层,而不是塞进 messages 数组里。翻译的时候要把它取出来,作为对话的第一段拼进去。第二是content字段的结构,Anthropic 支持 content 是字符串或者是一个 block 数组,block 里可能有type: "text"或者type: "tool_use"。工具调用这块最麻烦,因为 Claude Code 重度依赖工具调用,我得把 tool_use 和 tool_result 的往返完整映射到 llama.cpp 的对话模板里。
第三是流式响应的格式。Anthropic 的 SSE 事件类型有message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop这么几种,每种事件的 JSON 结构都不一样。llama.cpp 吐出来的是 token 流,我得自己组装成这些事件。这里有个细节:content_block_delta里的delta字段,文本增量用的是text_delta,工具调用增量用的是input_json_delta,两者不能混。
# 简化的请求翻译逻辑 def translate_anthropic_to_llama(req): messages = [] if req.get("system"): messages.append({"role": "system", "content": req["system"]}) for msg in req["messages"]: content = msg["content"] if isinstance(content, str): messages.append({"role": msg["role"], "content": content}) else: # 处理 block 数组,提取 text 和 tool 相关块 text_parts = [b["text"] for b in content if b["type"] == "text"] messages.append({"role": msg["role"], "content": "".join(text_parts)}) return { "prompt": apply_chat_template(messages), "max_tokens": req.get("max_tokens", 4096), "stream": req.get("stream", False), }3.2 KV 缓存 slot 的手动管理
llama.cpp 的 server 默认会创建若干个 slot,每个 slot 能独立处理一个请求。默认行为是请求来了就占一个空 slot,占满了就排队。我要做的是接管这个分配过程。
具体做法是绕过它的 HTTP 层,直接用它的 C API 或者通过一个薄封装来操作。每个 slot 我记录三个信息:当前缓存的前缀指纹、最后使用时间、当前是否被占用。调度器拿到一个请求后,先查指纹表,命中就直接把请求路由到对应 slot,没命中就找一个空闲的或者按 LRU 淘汰一个。
淘汰的时候有个坑:不能直接清空 slot,因为可能还有别的请求正在读它的缓存。我的做法是给每个 slot 加一个引用计数,只有当引用计数归零时才能被淘汰。这个引用计数在请求开始处理时加一,处理完减一。
注意:slot 数量不是越多越好。每个 slot 都要占一份 KV 缓存空间,slot 太多会导致单个 slot 能容纳的上下文变短。我实测下来,在 24G 显存的卡上,跑 7B 量化模型,开 4 个 slot 是比较平衡的,再多就开始频繁淘汰,反而拖慢整体。
3.3 算力分配的优先级策略
多 Agent 场景下,不是所有 Agent 都同等重要。主 Agent 负责协调,它的响应延迟直接影响整体进度,应该给高优先级。子 Agent 里,正在被主 Agent 等待的那个应该优先,其他可以稍等。
我在请求头里加了一个自定义字段X-Agent-Priority,调度器按这个字段排序。但光有优先级还不够,还得防止低优先级请求被饿死。我的做法是给每个请求算一个"等待补偿值",等待时间越长,补偿值越高,最终调度分数是优先级减去补偿值。这样高优先级请求先走,但低优先级请求等久了也能插队。
| 优先级 | 典型 Agent | 调度权重 | 说明 |
|---|---|---|---|
| P0 | 主协调 Agent | 100 | 延迟敏感,优先调度 |
| P1 | 被等待的子 Agent | 60 | 影响主流程推进 |
| P2 | 后台子 Agent | 30 | 可容忍延迟 |
| P3 | 预计算/预热请求 | 10 | 空闲时处理 |
3.4 上下文长度的动态控制
Claude Code 的对话会越来越长,如果不加控制,单个 Agent 的上下文很快就能吃满整个 slot 的 KV 空间。我的策略是给每个 Agent 设一个上下文上限,超过之后触发摘要压缩——把早期的对话用一个小模型总结成一段话,替换掉原始的多轮对话。
这个压缩过程本身也要算力,所以我把它放在 P3 优先级,只在系统空闲时做。压缩后的摘要会改变前缀指纹,所以压缩完成后要主动更新指纹表,避免后续请求命中错误的缓存。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
我是在 Ubuntu 上做的,其他系统思路一样,主要是编译 llama.cpp 那步要注意。
先拉 llama.cpp 的源码,编译的时候要开 server 和 CUDA 支持。编译参数里LLAMA_CUDA=1是必须的,不然跑起来是纯 CPU,慢到没法用。编译完成后会得到llama-server这个可执行文件,但我不用它的默认启动方式,而是把它当成一个库来调用。
git clone https://github.com/ggerganov/llama.cpp cd llama.cpp cmake -B build -DLLAMA_CUDA=1 -DLLAMA_SERVER=1 cmake --build build --config Release -j模型我选的是 7B 级别的指令微调模型,量化到 Q4_K_M。这个量化级别在质量和显存占用之间比较平衡,Q4 以下质量掉得明显,Q5 以上显存又吃不消。模型文件放在本地,启动时通过参数指定路径。
后端服务本身我用 Python 写,因为调度逻辑改起来快。Web 框架用 FastAPI,它原生支持异步和 SSE,正好适合做流式转发。依赖就三个:fastapi、uvicorn、httpx。
4.2 启动参数的计算与选择
启动 llama.cpp 后端时,几个关键参数直接决定了多 Agent 场景下的表现。
--ctx-size是总的上下文长度,这个要按 slot 数量来算。如果我开 4 个 slot,每个 slot 想支持 8K 上下文,那总 ctx-size 至少要 32K。但显存有限,所以我实际设的是 16384,4 个 slot 平均每个 4K,够单轮任务用,长对话靠摘要压缩来腾空间。
--n-gpu-layers设成 99,意思是所有层都放到 GPU 上。如果显存不够,可以调小这个值,让部分层留在 CPU,但速度会明显下降。
--parallel这个参数就是 slot 数量,我设成 4。前面说过,这个值要和 ctx-size 一起权衡。
./llama-server \ --model ./models/qwen2.5-7b-instruct-q4_k_m.gguf \ --ctx-size 16384 \ --n-gpu-layers 99 \ --parallel 4 \ --port 8080 \ --host 127.0.0.1启动后先别急着接 Claude Code,用 curl 发一个简单请求测一下,确认模型能正常出结果。这一步能排掉大部分环境问题。
4.3 调度器的实现
调度器是后端服务的核心,我用一个独立的协程来跑。它维护一个优先队列,请求进来先入队,调度协程不断从队列里取请求,决定路由到哪个 slot。
关键的数据结构是 slot 状态表,每个 slot 一个条目,记录指纹、引用计数、最后使用时间。调度协程取请求时,先算指纹,查表,命中就路由,没命中就找空闲 slot,没有空闲就按 LRU 加引用计数为零的条件淘汰一个。
class SlotManager: def __init__(self, num_slots): self.slots = [ {"fingerprint": None, "refcount": 0, "last_used": 0} for _ in range(num_slots) ] def acquire(self, fingerprint): # 先找完全匹配 for i, slot in enumerate(self.slots): if slot["fingerprint"] == fingerprint and slot["refcount"] == 0: slot["refcount"] += 1 slot["last_used"] = time.time() return i # 再找空闲 for i, slot in enumerate(self.slots): if slot["refcount"] == 0 and slot["fingerprint"] is None: slot["refcount"] += 1 slot["fingerprint"] = fingerprint slot["last_used"] = time.time() return i # 最后按 LRU 淘汰 candidates = [ (i, s) for i, s in enumerate(self.slots) if s["refcount"] == 0 ] if not candidates: return None # 全部占用,请求排队 i, slot = min(candidates, key=lambda x: x[1]["last_used"]) slot["refcount"] += 1 slot["fingerprint"] = fingerprint slot["last_used"] = time.time() return i释放 slot 的时候把引用计数减一,但不清空指纹,这样下次同样的请求还能命中。
4.4 把 Claude Code 指过来
Claude Code 支持通过环境变量指定 API 地址。设置ANTHROPIC_BASE_URL指向我本地的服务,再把ANTHROPIC_API_KEY随便填一个非空值(本地服务不校验),Claude Code 就会把请求发到我这里。
export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 export ANTHROPIC_API_KEY=local-test-key这里有个细节:Claude Code 启动时会做一些能力探测,比如发一个很小的请求确认服务可用。我的接入层要能正确响应这类探测请求,否则 Claude Code 会认为服务不可用直接退出。探测请求通常max_tokens很小,我直接返回一个固定的短响应就行,不用真的走推理。
4.5 多 Agent 并发实测
配置好之后,我开了三个 Claude Code 实例,分别扮演主 Agent、前端 Agent、后端 Agent。主 Agent 先发一个任务拆解请求,然后前端和后端 Agent 各自开始工作。
实测下来,第一个请求的 prefill 耗时约 1.2 秒,后续命中缓存的请求 prefill 降到 0.1 秒以内。三个 Agent 并发时,总吞吐比之前用远端服务时提升了大概 2.5 倍,主要收益就来自缓存复用和本地零网络延迟。
| 场景 | 首 token 延迟 | 总耗时 | 缓存命中率 |
|---|---|---|---|
| 单 Agent 冷启动 | 1.2s | 8.5s | 0% |
| 单 Agent 热缓存 | 0.1s | 6.2s | 100% |
| 三 Agent 并发 | 0.3s | 14.8s | 68% |
| 三 Agent 无缓存复用 | 1.1s | 31.5s | 0% |
5. 常见问题与排查技巧实录
5.1 请求卡住不返回
最常见的原因是 slot 全部被占用且引用计数没有正确释放。我遇到过一次,某个请求处理到一半抛了异常,引用计数没减,导致那个 slot 永远被占着。排查方法是看 slot 状态表,如果某个 slot 的引用计数长时间大于零但最后使用时间很久远,基本就是泄漏了。
解决办法是在请求处理外面包一层 try-finally,确保无论成功失败都释放引用计数。另外加一个看门狗,定期扫描引用计数超时未释放的 slot,强制回收。
5.2 缓存命中率低
如果发现指纹表里几乎全是未命中,先检查指纹的计算方式。我一开始把整个 messages 数组序列化后取哈希,结果每次请求的 messages 里都带了一个时间戳字段,导致指纹每次都变。后来改成只取 system prompt 加最近两轮对话的文本内容,命中率立刻上来了。
另一个原因是 slot 数量太少,频繁淘汰导致缓存留不住。这种情况要么加 slot,要么缩短上下文,要么提高摘要压缩的频率。
5.3 流式响应断流
Claude Code 对 SSE 的格式比较敏感,如果事件顺序不对或者字段缺失,它会直接断开连接。我踩过的坑是message_stop事件发早了,在最后一个content_block_stop之前就发了,导致 Claude Code 认为响应不完整。
正确的顺序是:message_start→content_block_start→ 若干content_block_delta→content_block_stop→message_delta→message_stop。每个事件之间要有正确的index字段,多个 content block 时 index 要递增。
5.4 工具调用解析失败
Claude Code 的工具调用依赖模型输出特定格式的 JSON。如果模型输出的 JSON 不合法,解析就会失败。我的做法是在接入层加一层容错,对模型输出做 JSON 修复——比如补全缺失的引号、去掉多余的逗号。同时给模型加一个更强的格式约束提示,降低出错概率。
提示:工具调用的 JSON 修复不要做得太激进,否则可能把正确的输出改错。我的策略是只修复明显的语法错误,语义层面的问题交给模型重试。
5.5 显存溢出
跑着跑着报显存不足,通常是 KV 缓存增长超过了预期。检查ctx-size和parallel的乘积是否超过了显存能承受的范围。另外,如果模型本身加载就占了大半显存,留给 KV 缓存的空间就很少,这时候要么换更小的量化,要么减少 slot 数量。
我整理了一个常见问题的速查表,方便对照排查。
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 请求卡住 | 引用计数泄漏 | 查 slot 状态表 | try-finally 包裹,加看门狗 |
| 命中率低 | 指纹计算不当 | 打印指纹对比 | 缩短指纹范围,去掉易变字段 |
| 流式断流 | 事件顺序错误 | 抓 SSE 原始流 | 按标准顺序发送事件 |
| 工具调用失败 | JSON 不合法 | 打印模型原始输出 | 加容错修复,强化格式提示 |
| 显存溢出 | KV 缓存超限 | 监控显存占用 | 降 ctx-size 或减 slot |
5.6 几个我踩过的坑
第一个坑是 llama.cpp 的版本兼容性。不同版本的 slot 管理接口有差异,我升级了一次版本后,原来的 slot 操作代码全废了。建议锁定一个稳定版本,不要盲目追新。
第二个坑是并发写入指纹表。调度器是单协程还好,但我后来为了性能改成了多协程,结果指纹表出现了竞态条件,两个请求同时写同一个 slot 的指纹,导致缓存错乱。加锁之后解决,但锁的粒度要控制好,太粗会影响并发。
第三个坑是模型输出的不确定性。同样的输入,模型两次输出的 token 数可能不一样,这会影响流式响应的组装。我的做法是在接入层做缓冲,攒够一定量的 token 再发,减少碎片化事件。
6. 后续可以继续优化的方向
这套东西跑通之后,我又想了几个可以继续打磨的点。一个是把摘要压缩做成增量的,不用等上下文满了才压,而是每轮对话后都压一点,保持上下文长度平稳。另一个是给调度器加一个预测模块,根据历史请求模式预判下一个请求会命中哪个 slot,提前把缓存准备好。
还有一个比较有意思的方向是把多个 Agent 的共享知识抽出来,做成一个公共的缓存前缀,所有 Agent 都能命中。比如项目的基础代码结构、通用的编码规范,这些内容每个 Agent 都要用,如果能让它们共享同一段 KV 缓存,省下来的算力相当可观。不过这需要更细粒度的缓存管理,llama.cpp 原生的 slot 机制可能不够用,得考虑自己实现一套前缀树来管理缓存块。
我在实际使用中最大的体会是,多 Agent 场景下的性能瓶颈往往不在模型本身,而在调度和缓存复用。把这两块做好,同样的硬件能撑起多好几倍的并发。这套后端我还在持续改,后面有新的进展再分享。