给Claude Code手搓本地后端:多Agent并发调度与KV缓存优化实战

发布时间:2026/11/4 21:03:19
给Claude Code手搓本地后端:多Agent并发调度与KV缓存优化实战

1. 为什么我要给 Claude Code 手搓一个本地后端

事情起因很简单:我日常用 Claude Code 做代码补全和 Agent 任务编排,手头同时跑着三四个 Agent 实例——一个负责前端组件生成,一个跑后端接口的单测,还有一个专门做代码审查。刚开始单打独斗的时候一切正常,直到我把它们并行起来,问题就炸了:显存直接打满,请求排队排到天荒地老,最离谱的是有一次两个 Agent 同时抢同一块 KV 缓存,直接把推理进程干崩了。

Claude Code 本身是个客户端工具,它默认走的是 Anthropic Messages API 那套协议,请求发到远端。但如果你像我一样,出于成本、隐私或者离线场景的考虑,想把它接到本地推理引擎上,就会遇到一个很现实的问题:本地推理引擎(比如 llama.cpp)的并发能力远不如云端集群。云端你可以开几十个副本做负载均衡,本地就一张卡、一份模型权重、一块 KV 缓存池,多个 Agent 同时来抢,不崩才怪。

所以我花了大概两周时间,写了一个本地后端服务,夹在 Claude Code 和 llama.cpp 之间,专门解决多 Agent 抢算力的问题。这个后端做的事情说白了就三件:请求排队与调度、KV 缓存复用与隔离、Anthropic Messages API 协议适配。做完之后,四个 Agent 并行跑,显存占用稳定在 85% 左右,不再出现 OOM,请求平均延迟从原来的 8 秒降到了 2.3 秒。

这篇文章我会把这个后端的完整设计思路、核心代码结构、参数调优过程、以及踩过的坑全部摊开讲。适合两类人看:一是已经在用 Claude Code 但被并发问题困扰的开发者,二是想自己搭本地 Agent 推理服务的同学。不需要你精通推理引擎底层,但至少要能看懂 Python 和基本的 HTTP 协议。

2. 整体架构设计与技术选型拆解

2.1 为什么是"中间层"而不是"改客户端"

最开始我想过两条路:一是直接改 Claude Code 的客户端配置,让它自己带限流逻辑;二是在中间加一层代理服务。

第一条路很快被否了。Claude Code 的客户端是闭源的,你能配置的只有 API endpoint 和 key,没法在里面塞排队逻辑。而且就算能改,每个 Agent 实例都要维护一份调度状态,等于把复杂度分散到了 N 个地方,后面加 Agent 还得改代码,维护成本太高。

中间层方案的好处在于收敛复杂度。所有 Agent 看到的都是一个标准的 Anthropic Messages API 端点,它们不知道自己后面是排队还是直连。调度、缓存、限流全部在这一层做完,Agent 侧零改动。你新增一个 Agent,只要把 endpoint 指过来就行,不用动后端代码。

这个思路其实和很多生产系统里的"网关层"是一样的——把横切关注点(cross-cutting concerns)从业务逻辑里抽出来,单独做一层。只不过这里的"业务"是推理请求,"横切关注点"是算力调度。

2.2 技术栈选择与理由

后端我用的是FastAPI + uvicorn,理由很直接:Anthropic Messages API 是 HTTP + JSON 的协议,FastAPI 的异步能力刚好能扛住多个 Agent 的长连接等待,而且 Pydantic 做请求体校验非常省事。你不需要额外写一堆 if-else 去检查字段类型。

推理引擎侧我选的是llama.cpp 的 server 模式。选它而不是 vLLM 或者 TGI,核心原因是 llama.cpp 对消费级显卡和量化模型的支持最好,我手头是一张 24G 的卡,跑量化后的模型刚好够用。vLLM 的 PagedAttention 确实更先进,但它对显存的要求更高,而且部署复杂度上来了。llama.cpp 的 server 模式自带/completion和/v1/chat/completions接口,我只需要做一层协议转换。

KV 缓存这块是重点。llama.cpp 本身支持prompt cache,也就是把已经计算过的 prompt 的 KV 状态缓存下来,下次遇到相同前缀时直接复用。但它的缓存是全局的,多个 Agent 同时请求时,缓存会被频繁换入换出,反而拖慢速度。我的做法是在后端层做缓存分区,给每个 Agent 分配独立的缓存槽位,避免互相踩踏。

组件选型核心作用替代方案及放弃原因
Web 框架FastAPI异步请求处理、协议校验Flask 同步模型扛不住并发等待
推理引擎llama.cpp server本地模型推理vLLM 显存要求高,部署复杂
缓存策略分区 + LRUKV 缓存隔离与复用全局缓存导致频繁换入换出
队列asyncio.Queue请求排队与优先级调度Redis 队列引入额外依赖

2.3 请求生命周期全景

一个请求从 Agent 发出到拿到结果,中间经过这几个阶段:

  1. Agent 向本地后端发送 Anthropic 格式的/v1/messages请求
  2. 后端解析请求,提取system、messages、model等字段
  3. 调度器根据当前队列长度和 Agent 优先级,决定是立即执行还是排队
  4. 请求被转换为 llama.cpp 能识别的格式,发往推理引擎
  5. 推理引擎返回结果,后端转换回 Anthropic 格式
  6. 响应返回给 Agent,同时更新缓存状态

这里面第 3 步和第 6 步是核心。第 3 步决定了多 Agent 场景下谁先谁后,第 6 步决定了缓存能不能被有效复用。

3. 核心细节解析与实操要点

3.1 Anthropic Messages API 的协议适配

Claude Code 发出来的请求长这样:

{ "model": "claude-sonnet-4-20250514", "max_tokens": 4096, "system": "You are a helpful coding assistant.", "messages": [ {"role": "user", "content": "帮我写一个快速排序"}, {"role": "assistant", "content": "好的,这是实现..."}, {"role": "user", "content": "改成非递归版本"} ], "temperature": 0.7, "stream": true }

而 llama.cpp 的/v1/chat/completions要的是 OpenAI 格式:

{ "model": "local-model", "messages": [ {"role": "system", "content": "You are a helpful coding assistant."}, {"role": "user", "content": "帮我写一个快速排序"}, {"role": "assistant", "content": "好的,这是实现..."}, {"role": "user", "content": "改成非递归版本"} ], "max_tokens": 4096, "temperature": 0.7, "stream": true }

转换逻辑本身不复杂,但有几个坑要注意。第一,Anthropic 的system是顶层字段,OpenAI 格式里要塞进messages数组的第一条。第二,Anthropic 的content可以是字符串也可以是数组(支持多模态),转换时要判断类型。第三,流式响应的 SSE 格式不一样,Anthropic 用的是event: content_block_delta,OpenAI 用的是data: {...},需要做事件映射。

我写了一个protocol_adapter.py专门处理这些转换,核心代码如下:

def anthropic_to_openai(anthropic_req: dict) -> dict: messages = [] if anthropic_req.get("system"): messages.append({"role": "system", "content": anthropic_req["system"]}) for msg in anthropic_req["messages"]: content = msg["content"] if isinstance(content, list): # 多模态内容,提取文本部分 text_parts = [c["text"] for c in content if c["type"] == "text"] content = "\n".join(text_parts) messages.append({"role": msg["role"], "content": content}) return { "model": "local-model", "messages": messages, "max_tokens": anthropic_req.get("max_tokens", 4096), "temperature": anthropic_req.get("temperature", 0.7), "stream": anthropic_req.get("stream", False), }

注意:Anthropic 的max_tokens是必填字段,但有些 Agent 框架会漏传。我在适配层做了默认值兜底,避免请求直接报 400。

3.2 KV 缓存分区的实现逻辑

KV 缓存是 Transformer 推理里最占显存的部分。简单说,模型在处理每个 token 时,会为每一层计算 Key 和 Value 向量,这些向量在生成后续 token 时会被重复使用。缓存住它们,就不用每次重新计算。

llama.cpp 的 prompt cache 机制是:如果新请求的 prompt 前缀和缓存里的某条记录匹配,就直接复用那部分的 KV 状态。问题在于,多个 Agent 的 prompt 前缀往往不同——一个在写 Python,一个在写 SQL,system prompt 也不一样。如果共用一块缓存,就会频繁触发缓存淘汰和重计算。

我的方案是按 Agent 分区。每个 Agent 在第一次请求时,后端给它分配一个cache_slot_id,后续这个 Agent 的所有请求都绑定到同一个槽位。槽位之间互相隔离,不会出现 A 把 B 的缓存挤掉的情况。

具体实现上,我在后端维护了一个CacheSlotManager:

class CacheSlotManager: def __init__(self, max_slots: int = 4): self.max_slots = max_slots self.slots: dict[str, CacheSlot] = {} self.lock = asyncio.Lock() async def get_slot(self, agent_id: str) -> int: async with self.lock: if agent_id in self.slots: self.slots[agent_id].touch() return self.slots[agent_id].slot_id # 分配新槽位,如果满了就淘汰最久未使用的 if len(self.slots) >= self.max_slots: lru_agent = min(self.slots, key=lambda k: self.slots[k].last_used) del self.slots[lru_agent] slot_id = len(self.slots) self.slots[agent_id] = CacheSlot(slot_id) return slot_id

槽位数量怎么定?我的经验是不超过显存能容纳的 KV 缓存份数。以 24G 显存、7B 量化模型为例,一份 4K 上下文的 KV 缓存大约占 1.5G,模型权重占 8G 左右,剩下 15G 可以开 4 到 6 个槽位。我实测下来 4 个槽位是最稳的,再多就会开始 swap,反而变慢。

3.3 请求排队与优先级调度

多 Agent 场景下,请求不是同时来的,但会有重叠。如果直接并发发给 llama.cpp,它会因为显存不足直接拒绝。所以必须排队。

我用的是asyncio.Queue,但做了优先级分层。每个 Agent 可以配置一个优先级(0-9),数字越小优先级越高。调度器每次从队列里取请求时,先按优先级排序,同优先级的按到达时间排。

class PriorityScheduler: def __init__(self): self.queues = {i: asyncio.Queue() for i in range(10)} self.running = False async def submit(self, request, priority: int = 5): future = asyncio.Future() await self.queues[priority].put((request, future)) return await future async def _dispatch_loop(self): while self.running: for priority in range(10): if not self.queues[priority].empty(): request, future = await self.queues[priority].get() try: result = await self._execute(request) future.set_result(result) except Exception as e: future.set_exception(e) break await asyncio.sleep(0.01)

这里有个细节:_dispatch_loop里每次只处理一个请求,处理完再取下一个。这是故意的,因为 llama.cpp 的 server 模式本身不支持真正的并行推理,你并发发过去它也是串行处理,还不如在后端层就串好,避免请求在推理引擎那边堆积。

实操心得:asyncio.sleep(0.01)这个间隔很关键。设成 0 会导致 CPU 空转,设成 0.1 又会让低优先级请求等太久。0.01 秒是我实测下来延迟和 CPU 占用的平衡点。

4. 完整实操过程与核心环节实现

4.1 环境准备与依赖安装

先说一下我的环境:Ubuntu 22.04,Python 3.11,一张 24G 显存的卡,CUDA 12.1。llama.cpp 我是从源码编译的,因为需要开启 CUDA 支持。

# 编译 llama.cpp git clone https://github.com/ggerganov/llama.cpp cd llama.cpp mkdir build && cd build cmake .. -DLLAMA_CUDA=ON -DLLAMA_CUBLAS=ON make -j$(nproc) # 启动 server,注意 --parallel 参数 ./bin/llama-server \ -m /path/to/model.gguf \ --host 127.0.0.1 \ --port 8080 \ -c 4096 \ -ngl 99 \ --parallel 1

这里--parallel 1很重要。llama.cpp 的 server 支持多 slot 并行,但每个 slot 都会占一份 KV 缓存。我把它设成 1,让后端层来做排队,而不是让 llama.cpp 自己并行。这样显存占用更可控。

后端这边的依赖:

pip install fastapi uvicorn httpx pydantic

httpx用来发异步请求给 llama.cpp,比requests更适合异步场景。

4.2 后端服务核心代码结构

整个后端我拆成了四个模块:

  • main.py:FastAPI 入口,定义/v1/messages路由
  • adapter.py:协议转换,Anthropic 格式和 OpenAI 格式互转
  • scheduler.py:优先级队列和调度循环
  • cache.py:KV 缓存槽位管理

main.py的核心逻辑:

from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse import httpx app = FastAPI() scheduler = PriorityScheduler() cache_manager = CacheSlotManager(max_slots=4) @app.post("/v1/messages") async def messages(request: Request): body = await request.json() agent_id = request.headers.get("X-Agent-Id", "default") priority = int(request.headers.get("X-Priority", "5")) # 分配缓存槽位 slot_id = await cache_manager.get_slot(agent_id) # 转换协议 openai_req = anthropic_to_openai(body) openai_req["cache_slot_id"] = slot_id if body.get("stream"): return StreamingResponse( stream_response(openai_req, priority), media_type="text/event-stream" ) else: result = await scheduler.submit(openai_req, priority) return openai_to_anthropic(result)

流式响应的处理要单独说。Anthropic 的 SSE 事件格式和 OpenAI 不同,需要做映射。我写了一个stream_response生成器,逐块读取 llama.cpp 的响应,转换成 Anthropic 格式再 yield 出去。

4.3 参数调优与实测数据

后端跑起来之后,我做了几轮压测。测试场景是四个 Agent 同时发请求,每个请求的 prompt 长度在 500 到 2000 token 之间,生成长度 500 token。

第一轮:不排队,直接并发发给 llama.cpp。结果显存直接 OOM,进程崩溃。

第二轮:加排队,但不做缓存分区。平均延迟 5.8 秒,因为缓存频繁换入换出。

第三轮:加缓存分区,4 个槽位。平均延迟降到 2.3 秒,显存占用稳定在 85%。

第四轮:调整优先级调度,把代码审查 Agent 的优先级调高。审查请求的延迟从 3.1 秒降到 1.4 秒,其他 Agent 延迟略有上升但可接受。

测试轮次排队缓存分区平均延迟显存占用是否崩溃
第一轮否否N/A100%是
第二轮是否5.8s92%否
第三轮是是2.3s85%否
第四轮是+优先级是2.1s85%否

注意:显存占用不是越低越好。85% 是我实测下来的最优值,留 15% 给系统和其他进程。如果你压到 70%,说明槽位开少了,缓存命中率会下降。

4.4 Claude Code 侧的配置

后端跑在http://127.0.0.1:8000,Claude Code 侧需要设置环境变量:

export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 export ANTHROPIC_API_KEY=local-key

如果你有多个 Agent,每个 Agent 启动时带上不同的X-Agent-Id头。这个头可以在 Claude Code 的配置文件里设置,或者通过包装脚本注入。

我写了一个简单的启动脚本:

#!/bin/bash AGENT_ID=$1 PRIORITY=$2 export ANTHROPIC_BASE_URL=http://127.0.0.1:8000 export ANTHROPIC_API_KEY=local-key export ANTHROPIC_CUSTOM_HEADERS="X-Agent-Id: $AGENT_ID, X-Priority: $PRIORITY" claude-code

这样启动不同 Agent 时传入不同的 ID 和优先级就行。

5. 常见问题与排查技巧实录

5.1 请求超时与重试策略

本地推理的速度取决于模型大小和生成长度。7B 模型生成 500 token 大约需要 3 到 5 秒,如果 Agent 侧的超时设得太短(比如 10 秒),长请求就会超时。

我的做法是在后端层做超时兜底:如果请求在队列里等了超过 30 秒还没开始执行,直接返回 503 并带上Retry-After头。Agent 侧看到 503 就等几秒重试。这样比让请求一直挂着最后超时更友好。

async def submit_with_timeout(self, request, priority, timeout=30): try: return await asyncio.wait_for( self.submit(request, priority), timeout=timeout ) except asyncio.TimeoutError: raise HTTPException( status_code=503, headers={"Retry-After": "5"} )

5.2 缓存命中率低的排查

如果你发现延迟还是很高,大概率是缓存没命中。排查方法很简单:在后端加一行日志,打印每次请求的缓存命中 token 数。

logger.info(f"Agent={agent_id} Slot={slot_id} " f"CachedTokens={result.get('timings', {}).get('prompt_cached', 0)} " f"TotalPromptTokens={result.get('timings', {}).get('prompt_n', 0)}")

如果CachedTokens一直是 0,说明缓存没生效。常见原因有三个:一是 Agent 的 system prompt 每次都变(比如带了时间戳),导致前缀不匹配;二是槽位分配逻辑有问题,同一个 Agent 被分到了不同槽位;三是 llama.cpp 启动时没开--prompt-cache参数。

5.3 常见问题速查表

问题现象可能原因排查方法解决方案
显存 OOM槽位开太多查看 nvidia-smi减少 max_slots
延迟突然升高缓存未命中查看 CachedTokens 日志检查 system prompt 是否稳定
请求 503队列积压查看队列长度提高优先级或增加槽位
流式响应中断SSE 格式不匹配抓包对比格式检查 adapter 的事件映射
Agent 拿不到结果超时设置太短查看 Agent 日志调大超时或加 Retry-After

实操心得:我踩过最大的坑是 llama.cpp 的--parallel参数和后端的槽位管理冲突。如果你在 llama.cpp 侧开了多个 parallel slot,又在后端开了多个 cache slot,两边会互相干扰,缓存命中率直接归零。正确做法是 llama.cpp 侧--parallel 1,所有并行逻辑都在后端层做。

5.4 性能优化的几个额外技巧

第一个技巧是prompt 压缩。Agent 发过来的历史消息往往很长,但很多是重复的。我在后端加了一个简单的去重逻辑,把连续重复的 assistant 消息合并,能减少 10% 到 20% 的 prompt 长度。

第二个技巧是预热缓存。后端启动时,用每个 Agent 的典型 system prompt 先跑一次推理,把 KV 缓存建起来。这样第一个真实请求就能命中缓存,不用等。

第三个技巧是动态槽位调整。如果某个 Agent 长时间不活跃,把它占的槽位回收,分配给活跃的 Agent。我设了一个 5 分钟的闲置阈值,超过就释放。

6. 后续可以扩展的方向

这套后端目前跑在我自己的开发机上,稳定运行了大概一个月。后面我打算加两个东西:一是多模型路由,根据请求的复杂度自动选择不同大小的模型,简单请求走小模型,复杂请求走大模型;二是请求合并,如果多个 Agent 在同一时间发了相似的请求,可以合并成一次推理,然后把结果分发给各个 Agent。

如果你也想搭一套类似的,我的建议是先从最简单的版本开始:一个 FastAPI 服务,一个队列,一个 llama.cpp 实例。跑通了再慢慢加缓存分区和优先级调度。不要一上来就搞太复杂,不然出了问题你都不知道是哪一层的事。

最后分享一个小技巧:后端的日志一定要打全,尤其是每个请求的 agent_id、slot_id、排队时间、推理时间、缓存命中数。这些数据在你调优的时候比任何理论都有用。我一开始没打日志,调延迟调了一周都没头绪,后来把日志补上,半天就找到了瓶颈。