Skip to content

Part 5: 服务层

HTTP 服务器、KV 持久化、API 兼容

1. HTTP 服务器

推理引擎做好了,但用户怎么用它?需要一个 HTTP 服务器接收请求、排队推理、流式返回结果。今天学习从 POSIX socket 到 SSE 流式输出的完整网络栈——全部手写,零依赖。

HTTP 服务器使用 线程池 处理并发连接。

设计决策推导:推理服务器的并发模型

问题 1:推理引擎是 C 代码,怎么让 Python/JS 客户端调用?
  └─ 方案:HTTP 服务器 — 任何语言都有 HTTP 客户端库

问题 2:推理很慢(decode 约 5-10 t/s),用户要等几十秒才看到输出?
  └─ 方案:SSE(Server-Sent Events)— 每生成一个 token 立即推送
     用户看到"逐字输出"的效果,体感延迟大幅降低

问题 3:推理是串行的(GPU 独占),但 HTTP 连接是并发的?
  └─ 方案:生产者-消费者模式 — client 线程接收请求并入队,
     worker 线程按 FIFO 顺序处理推理任务
     → HTTP 并发,推理串行,用队列解耦

问题 4:TCP 发送缓冲区满时,send() 返回 EAGAIN,丢数据怎么办?
  └─ 方案:poll() I/O 多路复用 — 等到 fd 可写再重新发送
     → 不阻塞其他线程,不丢数据

问题 5:每个连接一个线程,线程数不就爆炸了吗?
  └─ 实际不会:推理是瓶颈,同时只有 1 个活跃推理。
     并发连接数通常 < 10,线程开销可忽略。
     如果要做 C10K,需要改为事件驱动 + 协程,但对推理服务器不需要。

C 知识点

1. POSIX Socket

网络通信的基础 API:

c
int fd = socket(AF_INET, SOCK_STREAM, 0);      // 创建 TCP socket
bind(fd, (struct sockaddr *)&addr, sizeof(addr)); // 绑定地址
listen(fd, 16);                                  // 开始监听
int client = accept(fd, NULL, NULL);             // 接受连接
recv(client, buf, sizeof(buf), 0);               // 读取数据
send(client, buf, len, 0);                       // 发送数据
close(client);                                   // 关闭连接

ds4_server.c 的 listen_on创建监听 socket。

2. 每连接一线程模型

ds4_server 的架构:

推理是串行的(一个 GPU),但 HTTP 处理是并发的(多个线程)。

3. poll() I/O 多路复用

ds4_server 用 poll 处理慢客户端:

c
ssize_t w = send(fd, s, n, 0);
if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    struct pollfd pfd = {.fd = fd, .events = POLLOUT};
    poll(&pfd, 1, timeout);   // 等待 socket 可写
    continue;
}

当发送缓冲区满时,send 返回 EAGAINpoll 等待直到缓冲区有空间,避免忙等。

3a. 服务器启动选项

ds4-server 支持以下关键启动选项:

选项说明
--host绑定地址(默认 127.0.0.1,LAN 需显式 0.0.0.0
--port监听端口
--threads工作线程数
--cors启用 CORS,为浏览器 JavaScript 客户端添加 Access-Control-Allow-* 响应头,处理 OPTIONS 预检请求。不改变绑定地址
--chdir DIR启动时切换工作目录,使相对路径(模型、trace、KV cache 目录、metal/*.metal)相对于指定目录解析。适用于 service manager 从任意目录启动 ds4-server 的场景
--gpu-stats报告 GPU 统计信息

LLM 知识点

1. SSE 流式响应

SSE(Server-Sent Events)让服务器可以逐步发送数据:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {"choices":[{"delta":{"content":" world"}}]}

data: [DONE]

每条消息以 data: 开头,以两个换行结束。客户端收到一条就显示一条,实现"打字机"效果。

SSE Keepalive(防 TCP 超时)

长 prompt 的 prefill 可能持续数十秒,期间 SSE 流没有新数据。部分代理/负载均衡器会在 5-10 秒无数据后关闭连接。ds4_server 在 prefill 期间每 5 秒发送 SSE 注释行作为心跳:

: prefill\n\n

SSE 规范中 : 开头的行是注释,客户端会忽略,但足以保持 TCP 连接活跃。server_prefill_progress 结构体跟踪上次心跳时间,在 prefill 进度回调中检查是否需要发送。

2. OpenAI vs Anthropic API

ds4_server 同时兼容两种 API:

OpenAI 格式:

json
POST /v1/chat/completions
{"messages":[{"role":"user","content":"Hello"}],"stream":true}

Anthropic 格式:

json
POST /v1/messages
{"messages":[{"role":"user","content":"Hello"}],"stream":true}

差异:字段名不同、thinking 的表示方式不同、tool calling 格式不同。ds4_server 在内部统一处理,只是输入/输出的适配层不同。

所有三种 API 格式现在都在 usage 字段中报告 KV cache 命中详情:

json
// OpenAI: prompt_tokens_details.cached_tokens + cache_write_tokens
{"usage":{"prompt_tokens":25000,"prompt_tokens_details":{"cached_tokens":24000,"cache_write_tokens":600}}}

// Anthropic: cache_read_input_tokens + cache_creation_input_tokens
{"usage":{"input_tokens":400,"output_tokens":150,"cache_read_input_tokens":24000,"cache_creation_input_tokens":600}}

// Responses: input_tokens_details.cached_tokens + cache_write_tokens
{"usage":{"input_tokens":25000,"input_tokens_details":{"cached_tokens":24000,"cache_write_tokens":600},"output_tokens":150}}

cached_tokens 是从磁盘 KV cache 直接命中的 token 数,cache_write_tokens 是本次新增并写入 cache 的 token 数。OpenAI 的 cached_tokens 保持只读(不计入 write),以兼容标准客户端。

3. Responses API (/v1/responses)

OpenAI Responses API 端点,支持 Codex CLI 等 Agent 工具的多轮工具调用。与 /v1/chat/completions 的核心区别是请求-响应模型——每个响应对象包含完整的工具调用结果,客户端通过 call_id 绑定后续请求与 live KV 状态,实现毫秒级续接。Server 日志新增 Responses API 标记和进度日志改进,方便调试多轮工具调用。

2. KV Cache 持久化

长 prompt 的 prefill 需要数秒。多轮对话中如果每轮都从头 prefill,用户体验极差。将 KV Cache 序列化到磁盘,下次对话复用前缀——重复 prefill 从 10 秒降到 1 秒。

KV Cache 结构 序列化到磁盘,利用 mmap 快速重新加载。

C 知识点

1. 文件 I/O

KV 持久化已从 ds4_server.c 提取为独立模块 ds4_kvstore.c/h,实现格式和策略与服务器解耦。

ds4_kvstore 用标准文件 I/O 保存 KV cache(不用 mmap,避免增加 VM 映射):

c
FILE *fp = fopen(path, "wb");              // 打开文件写
fwrite(header, 1, 48, fp);                 // 写头部
fwrite(text, 1, text_len, fp);             // 写文本
ds4_session_save_payload(session, fp, ...); // 写 session 数据
fclose(fp);
rename(tmp_path, final_path);              // 原子重命名

原子写入模式:先写 .tmp.pid 临时文件,完成后 rename 为最终文件名。如果中途崩溃,临时文件不完整但不会损坏已有缓存。

2. SHA1 哈希

手写的 SHA-1 实现用于生成缓存文件名:

c
// 缓存 key = SHA1(token_ids 的字节)
static void sha1_tokens_hex(const ds4_tokens *tokens, int n, char out[41]) {
    sha1_ctx c;
    sha1_init(&c);
    for (int i = 0; i < n; i++) {
        uint8_t b[4];
        le_put32(b, (uint32_t)tokens->v[i]);   // token ID → 4 字节小端
        sha1_update(&c, b, sizeof(b));
    }
    sha1_final(&c, digest);
    hex20(digest, out);                         // 20 字节 → 40 字符 hex
}

为什么用 token IDs 而不是文本?

  • 同一段文本可能被不同分词器产生不同的 token 序列
  • token IDs 是精确的,避免文本编码差异导致的缓存 miss

3. 序列化格式

磁盘上的 KV cache 文件格式(由 ds4_kvstore 模块管理):

头部新增 model_id 字段区分不同模型变体的缓存。尾部(trailer)挂载协议特定数据——工具记忆映射、Responses API 可见性标记等——由调用方通过钩子注入,ds4_kvstore 本身不解析尾部内容。


LLM 知识点

1. 为什么需要 KV Cache 持久化?

编码代理(如 Claude Code)的工作模式:

  1. 发送很长的系统提示(~25K token)+ 用户消息
  2. 模型回复
  3. 下一轮发送更长的提示(系统 + 历史 + 新消息)
第 1 轮: [system(25K)] [user(100)] → model reply
第 2 轮: [system(25K)] [history(500)] [user(200)] → model reply
                ↑ 这 25K token 每次都要重新处理!

没有磁盘缓存:每轮都要 prefill 25K+ token(~10 秒) 有磁盘缓存:第一轮 prefill 后保存,后续轮次直接加载(~1 秒)

2. 缓存触发时机

cold:           首次 prefill 后,长 prompt 达到稳定前缀
continued:      推理过程中按间隔保存(绝对对齐前沿,约每 10K token)
evict:          新请求替换当前 session 前,保存旧的
shutdown:       服务器退出时保存
agent_system:   ds4-agent 系统提示稳定后保存
agent_session:  ds4-agent 会话状态保存

ds4_kvstore_chat_anchor_pos() 在冷保存时定位 chat task boundary(最后一个 <User> special token 之后),最大化跨独立 agent 会话的复用率。

驱逐策略:命中率衰减 + 当前存储保护

磁盘 KV cache 有容量限制(--kv-cache-budget-mb,默认 4096 MiB),满了需要驱逐旧条目。驱逐逻辑已提取到 ds4_kvstore 模块,评分公式:

score = (effective_hits + 1) × tokens / file_size

命中率衰减effective_hits 不是原始 hits 计数,而是按时间衰减:

c
// 半衰期 6 小时:不活跃的旧 checkpoint 的命中奖励逐渐归零
effective_hits = hits × 2^(-elapsed_seconds / 21600)
// 低于 0.01 时直接归零,避免微小的浮点奖励干扰排序

为什么需要衰减?工作负载变化(prompt 或 tool schema 改变)会让曾经热门的 checkpoint 无法匹配。不衰减的话,一个 24 小时前的 100-hit 条目会永远排在新条目前面。

当前存储保护evict 时传入刚写入文件的 SHA1,驱逐循环给这个文件最高评分(DBL_MAX),防止满缓存把刚保存的文件立即删掉——那等于白写了。如果文件本身超出预算,保护自动取消。

预算预检:保存前检查文件是否能放进预算(1% 余量),放不进的直接跳过,不触发无意义的驱逐循环。

分数相同时,按 last_used 时间排序——旧的先删。

预存储驱逐与兼容性加固

预存储驱逐(pre-store eviction):驱逐在写入新条目之前执行,传入待写入条目的上下文(model_idquant_bitsctx_sizetext)。这样避免了两个问题:

  1. 自驱逐:新条目刚写入就被自己的驱逐循环选中删除("进来就被选为自己的受害者")
  2. 前缀替代:如果新条目是旧条目的严格前缀扩展(同一 model_id + quant_bits、更长或相等的 ctx_size),旧条目自动获得评分惩罚,自然被新条目替代

锚点保护coldevictshutdown 类型的检查点是"锚点"(高价值的刻意保存),评分乘以 KV_CACHE_ANCHOR_REASON_SCORE_FACTOR,更难被驱逐。

兼容性加固kv_cache_existing_compatible):加载磁盘 KV 缓存时,严格校验文件头与当前运行时的兼容性——model_idquant_bitsctx_size、以及文件内容的 SHA1 完整性。不兼容的文件自动删除并重建。这修复了不干净关机后磁盘缓存"看似正常但 prefill 总是失败"的无限循环问题。

检查点保留(#673,24fa85e):成功加载一个磁盘 KV 检查点后,它不再被 cold-prompt 保存上限误删。之前首次 restore 一个更深的 cold / continued 检查点后,cold 上限可能顺手把它清掉——"刚加载就没了"。现在这些检查点保留,由磁盘预算驱逐按评分统一回收:区分"刚成功加载、值得留"与"预算满了才该淘汰"。

3. 前缀匹配

缓存查找过程:

4. 冷启动优化

cold 保存时会修剪尾部 token:

原始 prompt: 10000 个 token
trim 32 个尾部 token → 保存前 9968 个
对齐到 2048 边界 → 保存前 8192 个

为什么?BPE 分词器在追加文本时可能改变前面几个 token 的编码。修剪 + 对齐减少这种"边界 retokenization miss"。

Cold Checkpoint 的 Chat Task Boundary 锚定

冷 KV 检查点的裁剪位置现在锚定在 chat task boundary——最后一个 <User> special token 之后、第一个 <Assistant> special token 之前。这样最大化跨独立 agent 会话的复用率:保留稳定的用户角色脚手架(如 Codex 的 environment_context),而不保留属于对话历史的后续多轮 user 标记。

实现使用精确的 chat special token ID 在 token 层面定位锚点。如果 prompt 中没有有效的 chat anchor(非聊天场景或过短的 prompt),则回退到原有的 trim + 对齐启发式。

3. API 兼容层

服务器能跑了,但客户端期望 OpenAI 格式的请求和响应。今天学习手写递归下降 JSON 解析器、OpenAI/Anthropic API 双向兼容,以及 Tool Calling 的 DSML 解码——全部手写,没有用任何外部库。

API 兼容层为同一主题的 HTTP 服务器章节提供 OpenAI 兼容接口。

C 知识点

1. 手写 JSON 解析器

ds4_server 不依赖任何 JSON 库,自己实现了一个递归下降解析器:

c
// 跳过空白
static const char *json_ws(const char *p);

// 解析字符串(处理转义)
static const char *json_string(const char *p, char **out);

// 解析数字
static const char *json_number(const char *p, double *out);

// 解析整数
static const char *json_int(const char *p, int64_t *out);

// 跳过任意 JSON 值
static const char *json_skip_value(const char *p);

为什么不 cJSON / jansson?

  • 零依赖:整个项目不需要安装任何第三方库
  • 精确控制:只需要解析请求中的特定字段
  • 安全性:避免第三方库的未知漏洞

嵌套深度限制(防止栈溢出 DoS)

json_skip_value 内部使用带深度跟踪的版本递归解析:

c
#define JSON_MAX_NESTING 256

// 内部版本:跟踪递归深度
static const char *json_skip_value_depth(const char *p, int depth);
static const char *json_skip_array_depth(const char *p, int depth);
static const char *json_skip_object_depth(const char *p, int depth);

// 公开 API:入口包装
const char *json_skip_value(const char *p) {
    return json_skip_value_depth(p, 0);
}

为什么需要深度限制?忽略的请求字段可能包含 {"x":[[[...]]]]} 这样的深层嵌套结构。如果不加限制,递归调用会逐层消耗 C 调用栈,直到栈溢出才被操作系统拒绝。JSON_MAX_NESTING 256 在合理范围内截断,返回 false 拒绝请求。

2. JSON 解析模式

c
// 查找 JSON 对象中的字段
const char *p = body;
if (!json_expect(p, '{')) return error;
while (*p != '}') {
    char *key;
    p = json_string(p, &key);
    p = json_ws(p);
    if (!strcmp(key, "messages")) {
        p = parse_messages(p, &r->messages);
    } else if (!strcmp(key, "temperature")) {
        p = json_number(p, &r->temperature);
    } else {
        p = json_skip_value(p);    // 跳过不认识的字段
    }
    free(key);
    p = json_ws(p);
    if (*p == ',') p++;
}

关键:json_skip_value 让解析器可以跳过不需要的字段,只解析关心的字段。

3. UTF-8 处理

JSON 字符串中的 \uXXXX 转义需要转换为 UTF-8:

c
// 代理对处理:😀 → 😀 (U+1F600)
if (cp >= 0xD800 && cp <= 0xDBFF) {
    // 高代理 → 等待低代理
    uint32_t hi = cp;
    uint32_t lo;
    // \uYYYY
    cp = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
}
// UTF-8 编码
utf8_put(&out, cp);

解析器健壮性加固(issue #405)

手写解析器没有第三方库的兜底,每一个畸形输入都要自己挡。OpenCode 这类工具密集型客户端会在 messages 里塞进几十个 tool schema、上百轮 tool_calls(每个 arguments 还带转义和 emoji),任何一个边界处理疏漏都会让解析器崩溃或吃掉相邻的转义字符。本轮加固了三处:

  1. 缓冲区容量回绕buf_reserve 的翻倍增长循环 while (cap < need) cap *= 2cap 接近 SIZE_MAX 时会回绕回小值,形成无限循环。现在每步先检查 cap > SIZE_MAX / 2,溢出时直接取 need 并跳出。
c
while (cap < need) {
    if (cap > SIZE_MAX / 2) { cap = need; break; }   // 防回绕
    cap *= 2;
}
  1. 畸形代理对归一化:高代理 \uD83D 后若没有合法的低代理(如 \uD83DA,或孤立的 \uD83D),之前会把后续无关的 \uXXXX 转义"吃掉"拼成一个错误码点。现在 json_u16 失败时回退读指针、把高代理替换为 U+FFFD(替换字符),不消费下一个转义;utf8_put 也对超出 Unicode 范围或落在代理区间的码点统一归一化为 U+FFFD。

  2. OpenCode 形态压测:新增 test_json_parser_handles_tool_heavy_requests(32 个工具 schema × 24 轮 tool_calls,每轮 3 个带转义/emoji 参数的调用 + tool 结果)和 test_json_string_handles_surrogates(成对/孤立/坏低代理对)作为单元测试,锁定上述行为。

顺带把 stop-list 子串匹配的 strstr 返回值改为 const char *,让 GCC/ROCm 构建保持 -Wall -Wextra 无警告——纯 const 正确性,无行为变化。

解析器与协议加固(2026-08)

手写解析器没有第三方兜底,每个畸形输入都得自己挡。OpenCode / Codex 这类工具密集型客户端会塞进超大 messages 历史、重复字段、NaN 数值、不可能的模型尺寸——本轮修了一波(多数有 issue 编号),核心模式是"分配/替换前先校验,失败也不留半破坏状态":

JSON 请求解析

  • 双重释放(#539,8e49a5c):重复字段解析前先清空字符串输出,畸形重复不能再留下已释放指针。
  • 先解析成功再替换(#433,5c318ed):重复字段先进临时存储、解析成功后才释放旧值,覆盖 OpenAI/Anthropic/Responses/tool schema。
  • NaN 拒转整数(#541,8340f35):请求字段转 int 前先夹住非数与负值,避免未定义浮点转换。

模型文件与快照

  • 不可能的尺寸(#542/543/545,c7689db):safetensors/GGUF 长度字段按文件剩余字节封顶、交叉校验 FP8/FP4 张量存储、分配前拒绝不可能的元数据与张量计数;配套 size 检查溢出安全(a968c08)、超大快照分配前拒绝(#546,f02d79c)、agent cache 字符串按剩余文件封顶(#544,0fa15c6)。

工具与 reasoning 协议

  • 工具历史线性校验(#547,a169cff):边正扫边建"最近一次调用"查找,大 Responses/Anthropic 历史不再触发反复全历史搜索(O(n²) → O(n))。
  • 未终止 reasoning 路由(#509/665,9735ad8):截断的 reasoning 在非流式响应里导进 reasoning_content,不污染正文。
  • 重复 reasoning 不进流式答案(#678,fe2d3b0):prompt 已开 reasoning 块时不再要求模型重开,把暂定答案挂起到 tool call / 响应结束 / 第二个关闭标记证明它是答案还是又一段 reasoning。
  • 未关闭 reasoning 内恢复完整工具调用(#675,51a1c14):等完整调用、保留前面散文为 reasoning、结构化返回、协议文本不进流式输出。
  • 重复 GLM prompt 复用(#668,7694112):incoming GLM prompt 恰是 live session 开头时,回退到边界前一 token 重评估,不重建长 prompt。
  • agent edit [upto] 默认关闭b030961):新 DS4F 检查点对锚定 [upto] 编辑吃力,edit 默认改回精确 old/new 替换,[upto] 提示与自动插入改为 --edit-upto 显式 opt-in。

上述修复由 tests/ 里聚焦回归(355da75)、critical-fix 发布门(7adb2a4)与速度回归门(0dd0d36)锁定。


LLM 知识点

1. OpenAI Chat Completions API

POST /v1/chat/completions
{
  "model": "deepseek-v4-flash",
  "messages": [
    {"role": "system", "content": "You are helpful."},
    {"role": "user", "content": "Hello"}
  ],
  "temperature": 0.7,
  "max_tokens": 1024,
  "stream": true,
  "tools": [{"type": "function", "function": {...}}]
}
POST /v1/messages
{
  "model": "deepseek-v4-flash",
  "system": "You are helpful.",
  "messages": [
    {"role": "user", "content": "Hello"}
  ],
  "max_tokens": 1024,
  "stream": true,
  "thinking": {"type": "enabled"}
}

ds4_server 解析流程:

  1. 解析 messages 数组 → 渲染为 DeepSeek 聊天模板
  2. 解析 tools → 渲染为 DSML 工具格式
  3. 分词 → 推理 → 流式输出

差异:

  • system 是顶层字段(不在 messages 里)
  • thinking 控制不同的值
  • tool calling 格式不同

2b. Context Length 错误(协议标准化)

当 prompt tokens 数量达到或超过 context size 时,client_main() 在请求进入 job 队列之前就拦截并返回 400 错误。这比让推理引擎在后端失败更友好——客户端可以精确知道差多少 token。

OpenAI / Responses 格式:

json
HTTP/1.1 400 Bad Request
{
  "error": {
    "message": "Prompt has 16 tokens, but the configured context size is 16 tokens",
    "type": "invalid_request_error",
    "param": "messages",
    "code": "context_length_exceeded",
    "n_prompt_tokens": 16,
    "n_ctx": 16
  }
}

Anthropic 格式:

json
HTTP/1.1 400 Bad Request
{"type":"error","error":{
  "type":"invalid_request_error",
  "message": "Prompt has 20 tokens, but the configured context size is 20 tokens",
  "n_prompt_tokens": 20,
  "n_ctx": 20
}}

关键设计点:

  • param 字段自适应context_length_error_param() 根据请求类型返回不同参数名——"messages" 用于 chat、"prompt" 用于 completions、"input" 用于 Responses API
  • 边界精确匹配request_exceeds_context() 检查 prompt.len >= ctx_size(而非 >),因为 ds4_session_sync() 需要至少一个空闲 slot 才能生成第一个 token
  • 提前拦截:在 client_main() 中、入队之前执行检查,避免浪费 worker 线程时间

3. Tool Calling

模型生成工具调用的流程:

1. 请求中包含 tools 定义
2. ds4_server 把工具描述渲染为 DSML 格式(现在放在客户端 system prompt **之前**,以保护 KV cache 边界裁剪时优先切掉客户端内容而非工具 schema):
   <tool_calls>
   <invoke name="get_weather">
   <parameter name="city" string="true">Tokyo</parameter>
   </invoke>
   </tool_calls>

3. 模型输出 DSML 格式的工具调用
4. ds4_server 解析 DSML → 转换为 OpenAI/Anthropic 的 tool_call 格式
5. 客户端执行工具,返回结果
6. 继续对话

DSML 是 DeepSeek 的工具调用格式。ds4_server 做 DSML ↔ OpenAI/Anthropic 格式的双向转换。

畸形工具调用恢复

模型有时生成无法解析的 DSML(格式错误、嵌套异常等)。之前的做法是终止整个助手轮次,返回 finish_reason="error"。现在改为优雅降级

c
// parse_generated_message_for_response 包装核心解析器
bool parsed_ok = parse_generated_message(text, &content, &reasoning, &calls);
if (!parsed_ok) {
    // 解析失败 → 将原始文本作为普通助手消息返回
    content = xstrdup(text);
    finish = tool_parse_failure_recovery_finish(finish);
    // finish_reason="length" 保持不变,其他变为 "stop"
}

DSML 修复算法(try_repair_dsml

当模型因 max_tokens 截断而输出不完整的 DSML 时,try_repair_dsml 尝试修复:

  1. 从最后一个 </thinking> 之后搜索 DSML 起始标记
  2. 检测 DSML 风格(标准 <|DSML|tool_calls> 格式)
  3. 统计已打开但未关闭的标签,计算嵌套深度
  4. 按嵌套的逆序追加缺失的关闭标签(先关 <parameter> 再关 <invoke>

修复后的 DSML 重新送入解析器。如果修复失败,仍然回退到原始文本作为普通内容返回。

嵌套 DSML 参数解析

模型有时生成松散的嵌套结构——外层 <parameter> 省略 string 属性,内部包含子 <parameter>

xml
<parameter name="changes">
  <parameter name="file" string="true">main.py</parameter>
  <parameter name="content" string="true">...</parameter>
</parameter>

dsml_parse_nested_params_object 递归解析这些嵌套参数,将它们合并为 JSON 对象 {file: "main.py", content: "..."},作为外层参数的值。

DSML 解码状态机与采样温度控制

生成过程中,服务器跟踪模型在 DSML 中的位置,对不同部分应用不同温度:

c
typedef enum {
    DSML_DECODE_OUTSIDE,         // 不在工具调用内
    DSML_DECODE_STRUCTURAL,      // DSML 标签、JSON 标点
    DSML_DECODE_STRING_BODY,     // string=true 参数值
    DSML_DECODE_JSON_STRUCTURAL, // JSON 参数内的非字符串部分
    DSML_DECODE_JSON_STRING,     // JSON 字符串值
} dsml_decode_state;

关键设计:DSML 标签结构(<invoke><parameter>、JSON 花括号等)用温度 0 保证可解析性;参数载荷(文件内容、编辑文本等)使用正常采样温度避免重复。这解决了之前工具调用参数中出现重复文本的问题。

3b. 工具调用文本记忆(Tool Replay Memory)

问题:模型说的是 DSML,客户端来回传递的是 JSON。重新渲染 JSON 不一定产生相同字节——客户端可能重新排序对象键、改变空白等。如果下一轮渲染的 DSML 与模型之前看到的不一致,KV cache 就失效了。

解决方案:rax 基数树(radix tree)精确回放模型采样的原始 DSML。

rax.c / rax.h / rax_malloc.h  (~3,091 行)
├── rax:         基数树(压缩前缀树),O(k) 查找,k = key 长度
├── raxNew()     创建树
├── raxInsert()  插入 key-value
├── raxFind()    查找(返回 raxNotFound 表示不存在)
├── raxRemove()  删除
└── 来自 Redis 的成熟数据结构,经过大规模生产验证

ds4_server 的 tool_memory 架构:

工作流程:

  1. 模型生成 tool callparse_generated_message 捕获 raw_dsml 原始文本
  2. 分配随机 IDrandom_tool_id()/dev/urandom 读取 16 字节,生成如 call_a1b2c3...toolu_d4e5f6...
  3. 存入 tool_memory → 以 ID 为 key、raw_dsml 为 value 存入 rax 树
  4. 客户端回传历史 → 携带相同 ID 的 tool_call 出现在 messages 中
  5. tool_memory_attach_to_messages → 按 ID 查找,将保存的 raw_dsml 附加到 message 的 calls.raw_dsml
  6. 渲染时直接回放append_dsml_tool_calls_text 发现 raw_dsml 非空就直接使用,不再从 JSON 重新渲染

这样确保了 KV cache 的 token 序列与模型之前看到的完全一致。

工具记忆的磁盘持久化(KTM 格式)

工具记忆不只在内存中——它还能保存到 KV cache 文件中,在服务器重启后恢复:

text
KTM (Tool-id Map) 磁盘格式:
0   u8[3]  magic = "KTM"
3   u8     version = 1
4   u32    entry count

For each entry:
0   u32    tool id byte length
4   u32    sampled DSML byte length
8   bytes  tool id
... bytes  exact sampled DSML block

恢复流程:服务器启动后,在渲染请求前扫描 cache 文件,查找客户端历史中出现的 tool ID,提前加载对应的 DSML 映射。即使匹配的 KV 快照不是最终使用的那个,精确回放也能在重启后生效。

容量限制:默认最多 100,000 个 ID(--tool-memory-max-ids),最大 512 MiB 总字节。--disable-exact-dsml-tool-replay 可禁用精确回放,回退到规范 JSON-to-DSML 渲染。

3c. DSML 参数文本与工具结果文本规则

参数文本<parameter> 标签内):不再转义普通字符,只转义恰好匹配 DSML 关闭标签的文本:

c
// append_dsml_parameter_text
// 只有当文本中恰好出现 "</|DSML|parameter>" 时才转义开头 <
// 其他 < > & 字符原样保留

static void append_dsml_parameter_text(buf *b, const char *s) {
    const char *end = "</|DSML|parameter>";
    for (s = s ? s : ""; *s;) {
        if (!strncmp(s, end, strlen(end))) {
            buf_puts(b, "&lt;");   // 只转义关闭标签中的 <
            s++;
        } else {
            buf_putc(b, *s++);     // 其他字符原样输出
        }
    }
}

工具结果文本<tool_result> 标签内):保留原始文本不做转义,只保护 </tool_result> 关闭标签不被提前截断:

c
// append_tool_result_text
// 工具输出是数据——shell 输出、文件内容等包含大量 < > & 字符
// DeepSeek 渲染器保留原始文本,所以必须原样传入
static void append_tool_result_text(buf *b, const char *s) {
    const char *end = "</tool_result>";
    for (s = s ? s : ""; *s;) {
        if (!strncmp(s, end, strlen(end))) {
            buf_puts(b, "&lt;");   // 防止关闭标签被误匹配
            s++;
        } else {
            buf_putc(b, *s++);     // console.log('<<< < > >>>') 等原样保留
        }
    }
}

之前的 append_dsml_text_escaped 对所有 < > & 做 XML 转义,会导致 read-file 工具返回的代码被破坏(如 console.log('a < b') 变成 console.log('a &lt; b'))。现在参数和结果各有独立的转义函数,只保护各自的关闭标签。

3d. 检查点恢复与 Session Rewrite

当 tool replay 匹配失败时(如 ID 不存在于记忆中),ds4_server 使用 session rewrite API 尝试恢复:

c
// 统计工具回放匹配情况
typedef struct {
    int mem;          // 从 RAM 记忆中匹配
    int disk;         // 从磁盘 KV cache 中匹配
    int canonical;    // 需要规范重渲染
    int missing_ids;  // 找不到对应 ID
} tool_replay_stats;

匹配失败时,调用 ds4_session_rewrite_from_common()

  • 如果 session 检查点与新提示有公共前缀且只需扩展 → 正常 sync
  • 如果需要修改已采样的 token → 返回 DS4_SESSION_REWRITE_REBUILD_NEEDED
  • 服务器回退到磁盘 KV cache 检查点,或最终完整重新 prefill

渲染文本前缀匹配(替代 token ID 匹配)

KV cache 的查找键从 token ID 的 SHA1 改为渲染文本字节的 SHA1。文件名就是 <sha1>.kv,其中 SHA1 是 tokenizer 解码后的文本前缀。

为什么?模型可能生成一个 token,其解码文本在下一轮被客户端拆分为两个规范 prompt token(BPE 边界差异)。渲染字节前缀匹配可以处理这种情况:

c
// byte_prefix_match: 比较渲染文本是否匹配 cache 条目的前缀
// 命中后,使用 checkpoint 中存储的精确 token 作为前缀
// 只对新增的文本后缀重新分词
build_prompt_from_exact_prefix_and_text_suffix(...)

对齐前沿(Aligned Frontiers)

持续保存检查点不再按相对间隔,而是按绝对对齐前沿

c
// kv_cache_continued_store_target
// 只在绝对对齐位置(如 10k, 20k, 30k... token)保存
// 不相对于上次 cold/evict 保存点
// 防止早期 cold checkpoint 偏移整个保存计划

默认约每 10,000 token 保存一次(对齐到 2048 token 块)。这样长生成过程会留下均匀分布的重启点,而不依赖于第一个 cold 检查点落在哪里。

4. Thinking 模式

非 Thinking

Thinking

Think Max

ds4_server 在流式输出中分别处理 thinking 内容和正文内容:

  • OpenAI: reasoning_content delta vs content delta
  • Anthropic: thinking block vs text block

Thinking Checkpoint 重建优化

当 thinking 模式下请求因 length(达到 max_tokens)停止时,服务器之前会同步重建 thinking checkpoint,导致客户端感知延迟大幅增加。

修复:提取 should_canonicalize_thinking_checkpoint() 函数,在特定条件下跳过 canonicalization:

c
// 跳过 canonicalization 的条件:
// - 非 chat 请求或有 tools 的请求
// - prompt 本身保留了 reasoning 内容
// - 非 thinking 模式
// - finish reason 是 "error" 或 "length" ← 关键修复
// - 仍在 thinking 块内部

效果:4k context 的总时间从 30.0s 降到 17.1s,generation t/s 从 7.4 提升到 31.0。

Prefill 进度报告 — Suffix 模式

之前服务器报告 prefill 进度时使用绝对 token 数,但当有缓存命中时,已缓存的 token 不需要重新 prefill,导致进度显示不准确。

修复:改为报告 suffix 进度——只计算 cached_tokens 之后的 token 区间作为显示范围和百分比基数,更准确地反映实际需要处理的工作量。

无工具 Thinking 模式的 Cache 修复

当 thinking 模式的请求不使用工具时,渲染逻辑需要正确判断是否保留 reasoning 标记。如果判断错误,会导致字节前缀不匹配,造成 KV cache miss:

c
// chat_history_uses_tool_context: 判断会话是否涉及工具
// 如果请求有 tools 定义,或历史中有 tool/function 角色 → 工具上下文
// 工具上下文影响是否在渲染中包含 think 标记

// prompt_preserves_reasoning: 记录渲染后是否保留了 reasoning 标记
// 用于 KV cache 复用时的前缀匹配判断

Thinking 内的 Tool Call 隔离

DSML 工具调用只在 thinking 关闭后才能执行。实时解码器在 thinking 开启期间跳过 tool-marker 检测,最终解析器只在最后一个 </thinking> 之后搜索可执行 DSML。这防止 thinking 内容中的工具讨论被错误地当作结构化 tool call 处理——同一内容不会被同时作为 thinking 文本和 tool call 重复输出。

未关闭 <think> 内的工具调用恢复

上面的隔离规则有个边界情况:模型有时没关闭 thinking 就开了一个 DSML stanza。由于解码器刻意忽略 <think> 内的 tool marker,这种 turn 会一直生成到 token 上限,然后解析阶段把那次调用丢弃,agent 会话卡死(#318)。

修复策略是前向恢复而非重写已采样上下文:当未关闭的 think 块内出现一个完整的 stanza 开头时,强制喂入 </think> 加一个空行,让模型接着生成。实测在该位置模型足够强地预测一个全新的 stanza 开头,调用在可执行侧干净重启;那个悬空的起始标签无害地留在推理文本里。曾经试过"由服务器重新发出 stanza 开头"——但重复的开头会被模型读作"已经发起过调用"从而直接结束 turn,所以被否决。

关键细节:检测基于累积文本而非 marker token(故 marker 的分词方式无关),且只有完整的 stanza 开头才触发——引号片段或孤立的 < 不影响解码。DS4_SERVER_DISABLE_THINK_TOOL_RECOVERY 可恢复旧行为。

3e. Live Tool Continuation(工具调用快速续接)

工具调用后保留 live KV 状态,不再重建整个上下文。

问题:之前模型采样完工具调用后,服务器尝试 canonicalize live KV 检查点。百万 token 上下文下,这个重建就是一次近乎完整的 prefill,耗时 10 秒以上。

核心思想:采样的 KV 状态是最高保真度的状态。客户端可见的协议对象只应"选择"这个状态,而不是强迫服务器重建它。

两种协议的绑定方式:OpenAI Responses 通过 call_idfunction_call_output)绑定 /v1/responses 端点;Anthropic Messages 通过 tool_use_idtool_result)绑定 /v1/messages 端点。两种协议共享同一套 live KV 续接逻辑:ID 匹配 → 跳过 prefill → 只 tokenize 后缀。详见 misc/RESPONSE_API.mdmisc/ANTHROPIC_LIVE_CONTINUATION.md

5. ds4-eval 能力评估工具

ds4-eval 是内置的回归测试基准(~3340 行,独立可执行文件),不是排行榜跑分工具。它加载真实 GGUF 模型,渲染 DS4 聊天提示,逐 token 采样,然后在终端 TUI 中显示结果并自动评分。

sh
./ds4-eval -m ds4flash.gguf --trace /tmp/ds4-eval.txt

92 道题的组成(交替排列,由易到难):

来源数量特点
GPQA Diamond25研究生级科学,多选题
SuperGPQA25广域专业知识,跨域迁移(经审计清理)
AIME 202525竞赛数学,精确答案,零容错
COMPSEC17计算机安全与本地化,源自公开 CVE

设计意图:不是追求 92/92 满分,而是回答一个工程问题——在修改 kernel、量化、prompt 渲染、KV cache 或 tool streaming 后,模型是否仍然能解出一个有代表性的难题混合?

TUI 交互:分屏布局,ANSI 颜色,p 暂停、q 退出并打印报告、Up/Down 选择题目、Enter 运行选中的题目。--plain 禁用 TUI 用于自动化。上下文大小默认自动适配(--ctx 可选覆盖),--tokens 16000、thinking 模式开启。每个 case 的生成长度会被钳制到剩余上下文预算内,避免 KV 容量溢出导致整个评测中止。

Makefile 中 make 默认会编译 ds4-eval(和 ds4ds4-serverds4-bench 一起)。

答案提取器的假阴性修复(issue #319 / #321)

ds4-eval 不调用外部评分器,而是自己从模型输出里抽取答案再比对。抽取规则写得太简单会把"模型答对了"误判成"答错"——这是假阴性(false negative),会让回归测试低估模型精度。本轮修了四类:

1. 选择题:行首代词/冠词/缩写遮蔽(find_answer_letter。原来的规则是"答案行里第一个边界孤立的有效字母即答案"。但 10 选项(A–J)题里,行首的英文代词/冠词本身也是合法选项:

"Answer: I think it is C"          → I   (应得 C)
"Answer: A careful reading shows C" → A   (应得 C)
"Answer: I'll go with C."          → I   (应得 C)

修复:正扫时跳过"其后跟撇号"(缩写 I'll)或"其后是空白再接小写字母"(单词开头 A careful)的孤立大写字母——它们是散文不是选项。真正的独立答案("Answer: I.")由反向扫描兜底恢复,不受影响。

2. 选择题:被否定的干扰项抢答(mc_letter_is_negated。模型常先排除再下结论:

"Answer: It is not B, the answer is D"   → B   (应得 D)
"Answer: rules out C, leaving D"         → C   (应得 D)

修复:对每个候选字母,回退看前一个词是否是否定线索——notisn't/aren't/won'tn't 后缀)、rule(s/d) outeliminate/reject/except。是则跳过。线索集刻意做小、只改写明确的排除措辞,不做通用句法分析(那是 #321 标注的原则性上限)。关键不变量:检测只在答案标记之后、同行内进行,绝不跨换行回看——所以 "Answer: D, not B" 仍判 D(先到达真正的选择,再看到被否定的 B 也来不及抢答),这条 case 正是防住"取最后一个字母"这种朴素错误修复的护栏。

3. 整数题:答案行显示算式(find_integer_answer。AIME2025 大量要求 m+na+b+c 这类派生和,模型常把算式写出来:

"Answer: m+n = 256+37 = 293"   → 256  (取了第一个加数,应得 293)

修复:扫描范围限定在答案这一行(遇到换行截止,后续推理/脚注/年份不污染),且当行内有 = 时取最后一个 = 之后的整数(即陈述的结果);没有 = 时维持"行内第一个整数"(保留 "Final answer: 082"82 的宽松兜底)。

4. 重评分级:只评完成的用例(regrade_case_outcomeregrade_trace_file 用历史 trace 重跑评分。之前凡是带 MODEL_OUTPUT 的用例都被评,包括被中断的(STOPPED/SKIPPED/SWITCHED/ERROR)——它们的残缺输出会虚增 passed/failed 计数、制造假的"漂移"。现在抽出 regrade_case_outcome()只对 PASSED/FAILED(及无状态行的老 trace)评分,其余计入新的 not_graded 计数。例:3 个用例的 trace(1 PASSED、2 STOPPED)现在报 passed=1 not_graded=2 changed=0(原来是 passed=2 failed=1 changed=1)。

以上修复全部由 --self-test-extractors无模型用例锁定(不需要 GPU/模型即可跑),含 "D, not B" 护栏、整数算式、regrade 状态三类回归。

4. ds4-agent 自主编码代理

ds4-agent 是 ds4 项目新增的核心组件——一个完全在本地运行的 AI 编码代理,通过 ds4 推理引擎的 DSML 工具调用协议与文件系统、终端和浏览器交互。

架构

双线程架构:UI 线程处理用户输入和输出显示;Worker 线程独占推理引擎。两者通过共享缓冲区和条件变量通信。

10 个内置工具

工具功能需确认
read读取文件
more追加读取(分页)
write写入文件
list列出目录
edit搜索替换编辑
searchgrep 搜索
google_searchGoogle 搜索
visit_page访问网页
bash执行 shell 命令
bash_status检查后台任务状态

写文件、执行命令、访问网页等危险操作需要用户通过定时 yes/no 提示确认(默认 10 秒超时自动拒绝)。

edit 工具的锚定匹配与回归测试

edit(搜索替换)的核心是旧文本匹配——在文件里定位"要被替换的那段"。锚定语法 [upto ...] 让旧文本可以锚定到某个标记为止,处理"同一段文本在文件里多次出现"的歧义。这块匹配逻辑曾有两个边界缺陷:

  • old tail anchor not found after old head:当旧文本同时包含 head 和 tail 锚点时,head 匹配成功后 tail 锚点找不到,导致整次 edit 失败。
  • 修复后,ds4-agent 新增了锚定 [upto] 编辑的单元测试 harness,并纳入 make test 默认套件——以后旧文本匹配的回归会被默认测试立刻捕获。

这是"编辑工具的健壮性 = agent 可用性"的直接体现:agent 大量依赖 edit 做精确改动,匹配失败会逼它退化到整文件重写。

上下文压缩(Context Compaction)

当 token 使用量达到上下文的 85% 时触发软压缩

1. 保留系统提示 + 工具定义(不变)
2. 将对话历史摘要为一段简短文本
3. 保留最近的 N 个 token 逐字(verbatim tail)
4. 重建上下文:系统 + 摘要 + verbatim tail + 最新消息

如果软压缩后仍然溢出,触发硬压缩——更激进地截断历史。压缩协议详见 misc/COMPACT.md

会话持久化

agent 会话状态(对话历史、工作目录、压缩标记等)可以保存到 JSON 文件,支持中断后恢复。--session 指定会话文件路径。

非交互模式(--prompt)直接执行单个 prompt 后退出,适合脚本化使用。--cwd 设置工作目录。

ds4_web 浏览器工具

ds4_web.c 提供 google_searchvisit_page 两个工具,通过 CDP(Chrome DevTools Protocol)WebSocket 连接到本地 Chrome 浏览器(端口 9333):

ds4_agent → tool call: google_search("ds4 inference engine")
         → ds4_web: CDP WebSocket → chrome://devtools
         → 返回搜索结果文本

浏览器工具集成 agent 的定时 yes-no 确认机制——每次搜索或页面访问前都需要用户批准。Chrome 需以 --remote-debugging-port=9333 启动。

Agent 工具结果 DSML 包装

工具执行结果现在以 <tool_result>...</tool_result> XML 标签包装,通过 ds4_chat_append_message()"tool" 角色追加到对话历史。这样工具输出在 DSML 上下文中有明确的边界标记,避免与模型生成的文本混淆:

c
// 工具结果包装
ds4_chat_append_message(w->engine, &w->transcript, "tool", tool_result);
// tool_result 内容: "<tool_result>...</tool_result>"

/history 命令的显示逻辑会自动检测并剥离这些包装标签(agent_history_tool_result_payload()),用户看到的是干净的工具输出。畸形 DSML 调用会触发 agent_dsml_syntax_reminder 语法提醒。

合作式中断(Cooperative Interruption)

Agent 的 Ctrl+C 从暴力终止改为合作式中断——在安全检查点优雅停止,保留有效的 KV cache 状态:

中断回调 API:

c
// ds4.h — 中断回调
typedef bool (*ds4_session_cancel_fn)(void *ud);
#define DS4_SESSION_SYNC_INTERRUPTED 2
void ds4_session_set_cancel(ds4_session *s, ds4_session_cancel_fn fn, void *ud);

各阶段的中断行为:

  • Prefill:chunk 边界检查,保留到最后一个有效前缀的 KV 状态
  • Generation:token 边界检查,推送 EOS 后优雅退出
  • Compaction:中断时保留压缩前的对话状态

Web 工具同样支持中断——google_searchvisit_page 的阻塞操作(CDP WebSocket 连接、页面加载)都可通过 Ctrl+C 立即中断,不再需要等待 30+ 秒超时。

终端 raw 模式状态恢复

Agent 的交互式 REPL 基于 linenoise,运行在终端 raw 模式(关闭行缓冲、逐字节读取,这样才能拦截 Ctrl+C、做行编辑)。raw 模式是进程级终端状态——退出时若不恢复,shell 提示符会残留 raw 模式(回车不换行、输入不可见)。本轮同步修复了 raw 模式在多个退出路径上的泄漏:

  • quit / help:在这些非错误退出路径上也要正确恢复终端状态(之前只在正常退出路径恢复,quit/help 漏了)。
  • 中断恢复fix(agent): restore raw mode 确保 Ctrl+C 中断后 raw 模式状态一致,下一轮输入仍可正常编辑。

这是合作式中断的"终端侧"配套——engine 侧保留了 KV cache,REPL 侧也得把终端交还给用户一个干净的状态。

DSML 解析增强

流式 DSML 解析器经过多轮加固,处理模型输出的各种边缘情况:

标签校验agent_dsml_open_tag_is() 精确验证 DSML 开标签(标签名后必须跟 >、空格、Tab 或换行),替换了之前会匹配子串的 strncmp

文本中的 DSML 检测agent_stream_note_plain_dsml_byte() 检测正常文本中出现的 DSML 标记(模型在思考块或普通文本中误输出 <|DSML|),触发 AGENT_DSML_ERROR

隐式 Invoke 检测:当模型省略 <tool_calls> 外层包装,直接输出 <|DSML|invoke 时,解析器自动合成规范的 <tool_calls> 开标签,捕获模型意图。

参数关闭标签前缀追踪param_close_prefix 字段追踪 </|DSML|parameter...|> 关闭标签的部分匹配。这让终端可视化器可以在关闭标签完成前就隐藏部分标签,同时支持 greedy 采样判断。

畸形 DSML 错误报告agent_stream_malformed_dsml() 在终端显示红色 [invalid tool call: ...] 错误信息,同时保留 debug trace。

Marker 检测器泛化agent_dsml_marker_detector 结构体泛化了"尾部缓冲"模式,同时检测标准 |DSML| 和非标准 DSML|(缺少全角竖线)两种格式。

Agent 状态增强

Agent 状态栏新增三项实时信息,让用户了解推理引擎的内部状态:

Greedy 采样指示器 ❄️:当 DSML 解析器检测到模型正在生成结构标签(<invoke><parameter> 名等)时,采样被强制切换为 temperature=0 的确定性模式。状态栏显示 ❄️ 标记:

ctx 12K/128K | generation 42 tokens ❄️ 15.3 t/s 🔋

完整格式为 ctx <已用>/<总量> | generation <N> tokens [❄️] <速度> t/s [🔋],其中 ❄️ 标记 greedy 采样,🔋 标记使用电池供电(影响功率策略)。上例为简化展示。

agent_stream_wants_greedy_sampling() 根据解析器状态判断当前 token 是否应使用 greedy 采样——标签结构用 greedy 保证可解析性,参数内容用正常温度避免重复。

Prefill 速度显示:进度条内嵌实时 tokens/second 速率:

[████████░░░░] 342t/s

prefill_tps 字段记录 done / elapsed 的比值,渲染在进度条的未填充区域。

Web 工具状态消息 ✦google_searchvisit_page 执行时显示粉红色的系统状态消息:

✦ Searching Google for "ds4 inference engine"
✦ Opening page https://...

agent_publishf_system_status() 是可复用的状态发布函数,所有工具都可以使用。

6. 会话批处理(Session Batching)

原本"一个 GPU 同时只跑一个请求"对单用户够用,但多用户服务时 decode 阶段每 token 只读一个 token 的权重,GPU 算力大量闲置。--batched-session N 预分配 N 个独立常驻 KV session,把多个请求里就绪的 decode 步打包进一次内核调用,提升聚合吞吐。

详见 会话批处理术语表。核心机制:

  • 就绪的 decode 步一起评估(同一 batch kernel),长 prefill 交替进有限大小的 chunk,避免一个 prefill 堵死所有 decoder
  • 超过 N 的请求等空位;启用磁盘 KV 缓存时,空闲槽位被持久化、对话回来时再恢复,活跃请求永不被驱逐
  • 选 N 和 --ctx 让所有常驻 KV 分配都能装进显存

精确性保证:批处理是精确的——没有原生批处理内核时,按固定顺序跑受影响行,返回与逐 session 单跑完全相同的完整 logits。每个后端/模型的行为不同:

后端 / 模型session 执行
Metal,常驻 DeepSeek Flash支持时从两行起原生共享专家 + QKV 批处理;否则有序兜底
Metal,GLM 5.2有序精确兜底
CUDA,Flash(受支持的 TP/EP 多卡布局)原生 decode + 混合 prefill/decode,不支持的形状走精确兜底
CUDA 单卡(含 DGX Spark)有序精确兜底

N 个常驻 session 分配 N 份 KV 状态——"能装一次的上下文"未必能装 N 次。原生批处理提升聚合吞吐;有序兜底提供并发与公平性,但没有同样加速。原生会话批处理激活时 MTP 推测解码被禁用。

典型多用户服务部署(与CUDA 张量并行叠加):

sh
./ds4-server --cuda --cuda-tensor-parallel \
  --gpu-vram auto --gpu-devices 0,2,4,6,1,3,5,7 \
  --model "$MODEL" --ctx 100000 \
  --batched-session 16 --host 0.0.0.0

批处理 session 恢复竞态修复

批处理引入了"多个常驻 session 并发恢复"的新场景,暴露了一个竞态:session 恢复(从磁盘 KV 缓存加载、回放前缀重建 KV)若与并发的 decode/prefill 交错,可能读到半更新的状态。修复给恢复路径加了正确的同步——恢复完成前该 session 不参与批处理调度,避免"一边重建 KV、一边被拉进 batch"的不一致。