Appearance
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 返回 EAGAIN。poll 等待直到缓冲区有空间,避免忙等。
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 统计信息 |
3b. 客户端断开取消(Cancel on Disconnect)
浏览器刷新、curl Ctrl+C、代理超时——客户端断开后,服务器原本仍会把整个生成跑完:GPU 在给一个已经不存在的连接算 token。现在客户端线程在等待推理完成时每 100ms 探测一次连接,发现断开就取消在途任务(e9ded97,Matteo Collina)。
断开检测(client_socket_disconnected()):对连接 fd 做零超时 poll(POLLIN),可读就 recv 排干——读到 0 字节(EOF)即客户端关闭。本服务器是一请求一连接(响应头带 Connection: close),请求体已读完,任何后续可读字节只可能是客户端塞进来的 pipeline 字节,必须先排干它们才能看见藏在后面的 FIN。POLLHUP/POLLERR 同样判为断开。
取消链路:
关键设计:复用 合作式中断 的 ds4_session_set_cancel() 回调——agent 的 Ctrl+C 用的是同一套机制。取消在安全检查点生效(不破坏 session 约束),被取消的 slot 记录 decode_err = "client disconnected"。真实 socket 断开的回归测试(socketpair 探测、取消后进度回调惰性化)纳入 ds4_server_test(8a703b6),QA checklist 增加对应条目。
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\nSSE 规范中 : 开头的行是注释,客户端会忽略,但足以保持 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)的工作模式:
- 发送很长的系统提示(~25K token)+ 用户消息
- 模型回复
- 下一轮发送更长的提示(系统 + 历史 + 新消息)
第 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_id、quant_bits、ctx_size、text)。这样避免了两个问题:
- 自驱逐:新条目刚写入就被自己的驱逐循环选中删除("进来就被选为自己的受害者")
- 前缀替代:如果新条目是旧条目的严格前缀扩展(同一
model_id+quant_bits、更长或相等的ctx_size),旧条目自动获得评分惩罚,自然被新条目替代
锚点保护:cold、evict、shutdown 类型的检查点是"锚点"(高价值的刻意保存),评分乘以 KV_CACHE_ANCHOR_REASON_SCORE_FACTOR,更难被驱逐。
兼容性加固(kv_cache_existing_compatible):加载磁盘 KV 缓存时,严格校验文件头与当前运行时的兼容性——model_id、quant_bits、ctx_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),任何一个边界处理疏漏都会让解析器崩溃或吃掉相邻的转义字符。本轮加固了三处:
- 缓冲区容量回绕:
buf_reserve的翻倍增长循环while (cap < need) cap *= 2在cap接近SIZE_MAX时会回绕回小值,形成无限循环。现在每步先检查cap > SIZE_MAX / 2,溢出时直接取need并跳出。
c
while (cap < need) {
if (cap > SIZE_MAX / 2) { cap = need; break; } // 防回绕
cap *= 2;
}畸形代理对归一化:高代理
\uD83D后若没有合法的低代理(如\uD83DA,或孤立的\uD83D),之前会把后续无关的\uXXXX转义"吃掉"拼成一个错误码点。现在json_u16失败时回退读指针、把高代理替换为 U+FFFD(替换字符),不消费下一个转义;utf8_put也对超出 Unicode 范围或落在代理区间的码点统一归一化为 U+FFFD。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 解析流程:
- 解析 messages 数组 → 渲染为 DeepSeek 聊天模板
- 解析 tools → 渲染为 DSML 工具格式(先做规范化渲染,见下)
- 分词 → 推理 → 流式输出
工具 schema 的规范化渲染
工具 schema 是模型 prompt 的一部分(渲染在客户端 system prompt 之前,保护 KV cache 边界裁剪)。但客户端的 JSON 序列化器各有各的拼写——键序、空白、\uXXXX 转义还是原生 UTF-8——语义相同的 schema 会产生不同的 token 序列。把 HTTP 原文逐字转发进 prompt,"同一个工具集"每轮 token 都不同,KV cache 前缀全部 miss。
json_prompt_value_depth()(3196149)把请求里的 schema JSON 解析后按 DS4 分词器的规范词法重新渲染:保留对象键的插入序、用 Python 默认分隔符(", " 与 ": ")、用解码后的 UTF-8 而非客户端的 \u 转义。配套的 json_prompt_escape_n 是长度感知的——解码后的 JSON 字符串可能内嵌 U+0000,依赖 NUL 结尾的转义助手在这里不安全。语义相同的请求从此得到字节相同的 prompt 前缀,与下文的工具文本记忆(3b)互补:一个稳定"模型看到的工具定义",一个稳定"模型说过的话"。
差异:
- 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 线程时间
2c. ignore_eos:固定长度贪心解码(2026-09,e1147cd)
新的 chat/completions 请求字段 ignore_eos(bool):贪心解码且永不提前停止,直到生成满 max_tokens 个 token。为基准测试/评测设计——需要确定性的、恰好 N token 长的补全时,不能让模型中途碰到 EOS 就停。
契约上有意收紧:request_validate_ignore_eos() 要求显式 "temperature": 0——这个特性就是"贪心 + 不停",温度采样与它语义冲突。实现是把解码循环里的采样调用换成 ds4_session_argmax_ignoring_eos()(对 s->logits 取 argmax 但跳过所有 ds4_token_is_stop_for_think_mode() 命中的停止 token);推测路径对应 ds4_session_eval_speculative_argmax_ignoring_eos()(草稿在停止 token 处截断而非接受)。"ignore_eos" 已列进模型元数据的参数清单与请求 trace。选不出非 EOS token 时以 finish="error" 收场。
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 尝试修复:
- 从最后一个
</thinking>之后搜索 DSML 起始标记 - 检测 DSML 风格(标准
<|DSML|tool_calls>格式) - 统计已打开但未关闭的标签,计算嵌套深度
- 按嵌套的逆序追加缺失的关闭标签(先关
<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 保证可解析性;参数载荷(文件内容、编辑文本等)使用正常采样温度避免重复。这解决了之前工具调用参数中出现重复文本的问题。
字面工具数据、GLM 工具语法与推测边界(2026-09)
只转义自己的关闭分隔符。工具参数体里如果出现 </parameter> 之类的文本,老实现会全局转义、破坏字面数据(agent 编辑文件内容 ≠ 模型说的原话,KV cache 随之失效)。新头文件 ds4_tool_text.h 给出最小侵入的规则——工具体只对它自己的关闭分隔符做转义:
c
// ds4_tool_text.h
bool ds4_tool_text_needs_escape(const char *s); // 含自身关闭分隔符才需要
char *ds4_tool_text_escaped_close(const char *s); // 转义(实体形式)
char *ds4_tool_text_unescape(const char *s); // 还原
char *ds4_tool_text_partial_escape(...); // 流式:部分前缀也能安全转义agent 与 server 两条渲染路径共用这一套(d108ae4 / fc6414c),字面 tool/thinking 标记在参数数据里原样保留(fc6414c)。
GLM 的工具语法。GLM 不说 DeepSeek 的 <invoke> 方言,而是 <tool_call>...</tool_call>——ds4_server.c 新增 glm_tool_syntax(一个 dsml_syntax 实例)+ dsml_find_tool_start_from / find_tool_structural_text 等泛化解析入口,同一套状态机跑两种方言。被拆开的工具分隔符(<tool_ + call> 跨 token 边界)现在由采样跟踪器逐字节追踪(d077fa6),不会在流中丢失。
推测解码与工具边界的相遇。推测解码一次提交多个 token,但工具调用的起始标记必须在边界处被服务器看到才能切换 DSML 状态——两者天然冲突。本轮的合同(5b3cc8b / 930ab73 / 6eac69e / 759dd7c):
- 在工具/控制停止点,未被消费的推测 token 全部丢弃,从恢复的 logits 重采样——已返回的 token 流与逐 token 解码保持一致
- 有效的机会式草稿跨工具边界保留(
930ab73):草稿后缀若与目标贪心路径一致就不浪费 - 工具恢复受请求预算约束(
6eac69e),不修复被截断的工具动作、不超输出上限(759dd7c)——宁可把半截调用当普通文本返回,也不无中生有 - 工具观察出错时上报错误但不压缩对话(
e1711c2);DeepSeek 的纯文本工具结果恢复(558cd34)
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 架构:
工作流程:
- 模型生成 tool call →
parse_generated_message捕获raw_dsml原始文本 - 分配随机 ID →
random_tool_id()从/dev/urandom读取 16 字节,生成如call_a1b2c3...或toolu_d4e5f6... - 存入 tool_memory → 以 ID 为 key、raw_dsml 为 value 存入 rax 树
- 客户端回传历史 → 携带相同 ID 的 tool_call 出现在 messages 中
tool_memory_attach_to_messages→ 按 ID 查找,将保存的 raw_dsml 附加到 message 的calls.raw_dsml- 渲染时直接回放 →
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, "<"); // 只转义关闭标签中的 <
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, "<"); // 防止关闭标签被误匹配
s++;
} else {
buf_putc(b, *s++); // console.log('<<< < > >>>') 等原样保留
}
}
}之前的 append_dsml_text_escaped 对所有 < > & 做 XML 转义,会导致 read-file 工具返回的代码被破坏(如 console.log('a < b') 变成 console.log('a < 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 检查点落在哪里。
会话回滚与恢复的状态完整性(2026-09,233eeb8)
一次"回滚"要恢复的不只是 token 序列,还有每族模型各自的增量状态。本轮系统性地补齐:
- GLM 递归 + 索引器状态:推测被拒后,KDA 递归状态矩阵与 pool-4 索引器缓存都要跟着 token 前缀一起回滚——此前只回滚了 KV,递归层"记着未来"
- 不安全的 DeepSeek rewind 直接失效(而非带着错位状态继续);agent 保留的前缀重建时连同图像一起重建
- TP 恢复:磁盘缓存恢复在两个 rank 上重建保存的 token 前缀(恢复即 prefill,见 张量并行);GLM TP 推测周期有了显式命令与共享 token 上限(协议 version 10——两端必须一起升级)
- 内存记账:Metal GLM 的图/streaming 内存现在把 live 与已计划的会话都计入——"下个请求马上要来"不能再把当前请求挤出预算
配套 make test-session-state(tests/test_session_state.c,356 行):经多模态 API 提交的纯文本观察必须与普通消息 token 完全一致(Flash/PRO/GLM 三族,含关闭包装的转义)——过去一次拒绝被伪装成"上下文耗尽",就是这类不一致的锅。另 c0a6119 修了内存快照的最后一个字节:fmemopen 的终止符需要独占一个保留字节(payload 长度不变),快照的扩容/复用路径不再依赖模型即测。
4. Thinking 模式
非 Thinking
Thinking
Think Max
ds4_server 在流式输出中分别处理 thinking 内容和正文内容:
- OpenAI:
reasoning_contentdelta vscontentdelta - Anthropic:
thinkingblock vstextblock
V4.1 的数值努力档(--think-level / /think N,2026-09)
V4.1 Flash 把上面的三档开关变成连续旋钮:--think-level 25(CLI)/ /think 25(REPL 与 agent),1–100 设置推理努力、0 关闭;--think = 75、--think-max = 100。仅 V4.1 引擎接受,其他模型直接拒绝。编码进现有 ds4_think_mode 枚举的方式很省:DS4_THINK_LEVEL_BASE + level(基值 1000),101 档不用新类型;ds4_think_mode_parse_level 只认纯数字。渲染出来是紧跟 BOS 的 <|System|>Reasoning Effort: N (range 1-100, ...) 文本(ds4_deepseek41_reasoning_effort_text,ds4_chat_append_think_prefix 统一了 GLM 的努力文本、V4 的 max 前缀与 V4.1 数值档三条路径)。
换档的代价是整段 KV 重建:努力文本在最前面,换档即改写最早期的 token,ds4_tokens_starts_with 前缀匹配失败、ds4_session_invalidate + 重建。agent 的实现(worker_apply_requested_think)有个优雅细节:恢复旧会话时逐档渲染全部 101 个候选前缀、按最长匹配还原当时的档位——历史会话在哪个努力档下产生,就接着用哪个。agent 忙碌时 /think N 排队、本轮结束后生效("scheduled after this turn");换档还会平移 images[i].token_start(图像块位置跟着新前缀走)。API 侧 reasoning_effort 字段仍只接受命名档("max"/"high"/"none")——数值档暂时是 CLI/agent 专属。
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 可恢复旧行为。
截断工具调用的恢复
上面的前向恢复只认完整的 stanza。若模型在未关闭的 thinking 里开了工具调用的头、又在 token 上限处被截断,解析阶段仍会把这段半成品丢掉——agent 会话照旧卡死。0ead8a8 把 tool_call_status_inside_thinking() 的检测结果从二值改成三态(THINK_TOOL_NONE / THINK_TOOL_STARTED / THINK_TOOL_COMPLETE):
- COMPLETE → 照旧走前向恢复(注入
</think>让模型续写) - STARTED(生成结束时仍是半成品)→ 置
saw_tool_start = true,把截断文本交给上文 §DSML 修复算法的try_repair_dsml——按嵌套逆序补齐关闭标签,能恢复多少恢复多少
一句话:完整调用前向恢复,截断调用走 DSML 修复——两条路径都保证 agent 不因畸形/截断的工具输出而停摆。
3e. Live Tool Continuation(工具调用快速续接)
工具调用后保留 live KV 状态,不再重建整个上下文。
问题:之前模型采样完工具调用后,服务器尝试 canonicalize live KV 检查点。百万 token 上下文下,这个重建就是一次近乎完整的 prefill,耗时 10 秒以上。
核心思想:采样的 KV 状态是最高保真度的状态。客户端可见的协议对象只应"选择"这个状态,而不是强迫服务器重建它。
两种协议的绑定方式:OpenAI Responses 通过 call_id(function_call_output)绑定 /v1/responses 端点;Anthropic Messages 通过 tool_use_id(tool_result)绑定 /v1/messages 端点。两种协议共享同一套 live KV 续接逻辑:ID 匹配 → 跳过 prefill → 只 tokenize 后缀。详见 misc/RESPONSE_API.md 和 misc/ANTHROPIC_LIVE_CONTINUATION.md。
3f. 对话前缀条件化(--prefix-file,2026-09)
--prefix-file FILE(ds4 与 ds4-agent)在真实 prompt 之前预填一段多轮对话前缀——用 chat 模板渲染后经 ds4_session_sync 一次性 prefill 进 KV,再接 live prompt(one-shot 与 REPL 都支持,build_chat_prompt 在 ds4_cli.c)。详见 前缀条件化术语表。
实现是新模块 ds4_prompt_prefix.c/h:文件格式是整行 USER: / ASSISTANT: 标记(必须以 USER: 开头、交替、以 ASSISTANT: 结尾);ds4_prompt_prefix_parse/_load/_free 负责解析,assistant 轮结尾补 EOS——除 GLM-DSA 外(其模板对稠密层的处理不同)。上游文档给出的用途是改写拒绝行为(引用 Many-shot Jailbreaking 研究:长前缀里的示范对话能强力条件化后续行为)——这也是为什么它是个显式 CLI 旗标而非隐藏默认。回归 tests/test_prompt_prefix.c。
sh
# prefix.txt:
# USER: You are a calculator that always answers directly.
# ASSISTANT: 6*7 = 42
./ds4 -m model.gguf --prefix-file prefix.txt -p "9*13"3g. V4.1 聊天模板与 DSML 变体(2026-09)
V4.1 Flash 带来第四套模型语法(SERVER_MODEL_SYNTAX_DEEPSEEK41,server_model_syntax_for_engine() 按 ds4_engine_is_deepseek41() 分发;模型 id "deepseek-v4.1-flash")。服务器侧三块新工程:
1. 合并轮次模板 render_deepseek41_chat_ordered():BOS 后一个合并的 <|System|>(装推理努力文本 + 工具 prompt),user 侧轮次用 \n\n 焊接、工具结果作为连续 <tool_result> 块塞进同一个 <|User|> 轮,每个 assistant 轮都发 <|Assistant|><think>…</think>(thinking 关闭时也发空 think 块——模板形状恒定,KV 前缀才稳定)。完整 prompt 与 live 工具尾部走同一个渲染器——两套代码画出的模板边界不一致是缓存复用的经典事故源。
2. DSML 拼写变体:V4.1 的工具标签加空格(<|DSML| calls> vs V4 的 <|DSML|tool_calls>、闭合 > 前也有空格),流式探测、修复器(try_repair_dsml 改为按首个命中标签侦测风格而非 strstr 优先级)与 dsml_syntaxes[] 修复表全部参数化。agent 侧的语法提醒 prompt 只重写可信的示例文本,绝不动模型采样出来的正文。
3. 并行工具结果的定序:Anthropic 客户端可以并行回多个 tool_result,到达顺序 ≠ 调用顺序。ds41_message_views() 用解析时记录的 tool_result_span{id} 把消息切成文本/工具片段,ds41_order_messages() 用 rax id→index 映射把并行回复重排回所属 assistant 消息的调用顺序;纯输出侧的续接(Responses/Anthropic live continuation)由 live_tool_call_order() + render_request_live_tool_tail() 从 live 槽的 call_ids 恢复采样顺序。配套加固:Anthropic 初始 system 轮 memmove 到下标 0、重复 "content" 成员直接拒绝。
reasoning_effort API 字段原先只接受命名档("max"/"high"/"none");2026-09 中旬 ecf7a93 起也接受数值字符串("50" → DS4_THINK_LEVEL_BASE + 50,0–100,超界/非纯数字拒绝)——动机是 pi.dev 这类只会发数字、发不了 think_level 的客户端。共享解析器 parse_reasoning_effort_value 同时服务 OpenAI Chat、Responses、Anthropic thinking 与内部字段,测试覆盖 0..100 全档与 "-1"/"101"/"1.5"/"50x" 拒绝。
3h. Qwen3.8 模型语法与流式修复(2026-09)
Qwen3.8 Flash Next 带来第五套模型语法(SERVER_MODEL_SYNTAX_QWEN,模型 id qwen3.8-flash-next + -chat/-reasoner 别名)。工具调用用原生 XML 风格式 <tool_call><function=name><parameter=key>value</parameter></function></tool_call>(对照 GLM 的紧凑 <tool_call>name<arg_key>v</arg_key> 与 DeepSeek 的 markdown 变体);<think>…</think> 内联在答案前。三轮流式/解析修复都值得记:
1. 工具内容流式(d31089d):guard_second_reasoning(think 模式 + 带工具时置位)本为 DeepSeek 的"第二轮推理重路由进 reasoning_content"而设,套在 Qwen 上却让带工具的回答攒到生成结束才吐。修复用 stream_needs_second_reasoning_guard() 按模型语法豁免 Qwen——守卫逻辑不是普适的,是 DeepSeek 采样行为的补丁。配套 39bc628:流 chunk 可能把 </think> 切在标签中间(</thi + nk>),两个流式 API 都要跨 chunk 拼标签,且不能把字面 </thi 漏给客户端。
2. 参数字面标签(efc27d6):参数值里出现字面 </tool_call>/</think>(用户让模型写"怎么闭合标签"的代码)曾提前终止解析——服务器侧改用 find_tool_structural_text()(跳过工具参数载荷的结构化定界符搜索,不再 find_last_substr 裸找),agent 侧 AGENT_DSML_PARAM_VALUE 状态机只等自己的 param_close 定界符、EOF 拒绝未闭合值。按字节喂入的回归锁定 content 含字面 </tool_call> </think> <think> 的用例。
3. GLM 工具块内联渲染(fff391e):append_glm_tool_calls_text() 重建工具块时两边多包了换行,而 GLM 的 tokenizer.chat_template 任何变体都不带这个空白。同进程内不可见(live 轮走原始采样字节),客户端回放历史走重建路径才露馅:可见转写派生的检查点 key 差 3 字节,重启冒烟测试本该重 prefill ~14 token 的对话重 prefill 了 3800/11992——冷 prefill 也在渲染一个模型没训练过的形状。修复后原始/重建两条渲染路径与模板字节一致(test_glm_tool_calls_render_matches_template)。这是 §3d "渲染一致性是缓存复用前提"的又一实例。
同批:63bbba4 让 /v1/models 报实际加载的 GLM 模型 id(此前写死 glm-5.2,加载 5.3 也这么报)。
5. ds4-eval 能力评估工具
ds4-eval 是内置的回归测试基准(~3340 行,独立可执行文件),不是排行榜跑分工具。它加载真实 GGUF 模型,渲染 DS4 聊天提示,逐 token 采样,然后在终端 TUI 中显示结果并自动评分。
sh
./ds4-eval -m ds4flash.gguf --trace /tmp/ds4-eval.txt92 道题的组成(交替排列,由易到难):
| 来源 | 数量 | 特点 |
|---|---|---|
| GPQA Diamond | 25 | 研究生级科学,多选题 |
| SuperGPQA | 25 | 广域专业知识,跨域迁移(经审计清理) |
| AIME 2025 | 25 | 竞赛数学,精确答案,零容错 |
| COMPSEC | 17 | 计算机安全与本地化,源自公开 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(和 ds4、ds4-server、ds4-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)修复:对每个候选字母,回退看前一个词是否是否定线索——not、isn't/aren't/won't(n't 后缀)、rule(s/d) out、eliminate/reject/except。是则跳过。线索集刻意做小、只改写明确的排除措辞,不做通用句法分析(那是 #321 标注的原则性上限)。关键不变量:检测只在答案标记之后、同行内进行,绝不跨换行回看——所以 "Answer: D, not B" 仍判 D(先到达真正的选择,再看到被否定的 B 也来不及抢答),这条 case 正是防住"取最后一个字母"这种朴素错误修复的护栏。
3. 整数题:答案行显示算式(find_integer_answer)。AIME2025 大量要求 m+n、a+b+c 这类派生和,模型常把算式写出来:
"Answer: m+n = 256+37 = 293" → 256 (取了第一个加数,应得 293)修复:扫描范围限定在答案这一行(遇到换行截止,后续推理/脚注/年份不污染),且当行内有 = 时取最后一个 = 之后的整数(即陈述的结果);没有 = 时维持"行内第一个整数"(保留 "Final answer: 082" → 82 的宽松兜底)。
4. 重评分级:只评完成的用例(regrade_case_outcome)。regrade_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 状态三类回归。
Hard 能力套件(2026-09,29aa726)
92 题核心套件之外新增 hard 套件:50 道难题装进独立数据文件 ds4_eval_cases.c(1,118 行)/ .h——每道题是 eval_case 结构(来源、id、领域、题面、选项、答案、别名、answer_kind、suites 掩码、max_tokens),来源四个有许可证的数据集:MMLU-Pro、OlympiadBench、LiveBench(reasoning 子集)、NIST Juliet(安全 C/C++,CC0-1.0)——eval_hard_sources 带源 URL 与许可证,EVAL_DATA.md 记录出处,licenses/Apache-2.0.txt 随树分发。
答案判定也超出单选:eval_answer_kind 支持 EVAL_ANSWER_ORDERED_SEQUENCE(有序序列)与 EVAL_ANSWER_LINE_SET(行集合);套件掩码 EVAL_SUITE_CORE | HARD | HARD_SMOKE。新 CLI:--suite {core,hard,hard-smoke}、--case-id / --domain / --source 过滤、--list-cases、--validate-cases(校验数据完整性)、--retry-incomplete。配套 make test-quality-api(tests/test_quality_api.c)。
同一轮的质量工具链(PR #996):gguf-tools/quality-testing/ 新增 validate_scores.py(评分器覆盖率完整性)、compare_frontier_logits.py(全向量数值对比——不只是比 argmax)、render_glm_references.py 与 8 例 GLM FP8 长上下文参考集(prompt/续写/响应 + manifest.tsv);score_official.c 修了 null API 响应的 logprob 解析。长上下文改动的回归从此有全向量参照,而非只看选中 token。
4. ds4-agent 自主编码代理
ds4-agent 是 ds4 项目新增的核心组件——一个完全在本地运行的 AI 编码代理,通过 ds4 推理引擎的 DSML 工具调用协议与文件系统、终端和浏览器交互。
架构
双线程架构:UI 线程处理用户输入和输出显示;Worker 线程独占推理引擎。两者通过共享缓冲区和条件变量通信。
内置工具(11 个 + 可选 view_image)
| 工具 | 功能 | 需确认 |
|---|---|---|
read | 读取文件 | 否 |
more | 追加读取(分页) | 否 |
write | 写入文件 | 是 |
list | 列出目录 | 否 |
edit | 搜索替换编辑 | 是 |
search | grep 搜索 | 否 |
google_search | Google 搜索 | 是 |
visit_page | 访问网页 | 是 |
bash | 执行 shell 命令 | 是 |
bash_status | 检查后台任务状态(参数 job/pid/refresh_sec) | 否 |
bash_stop | 停止后台任务(SIGKILL 截止期,2026-09 新增) | 否 |
--vision 时额外启用 view_image(读图 → 编码 → 描述,见 §7)。
写文件、执行命令、访问网页等危险操作需要用户通过定时 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%(AGENT_COMPACT_SOFT_PERCENT),或剩余空间低于"压缩储备"(agent_worker_should_compact:min(8192, ctx/8) 与 agent_compact_reserve_tokens()+128 取较大者)时触发软压缩:
1. 保留系统提示 + 工具定义(不变)
2. 将对话历史摘要为一段简短文本
3. 保留最近的 N 个 token 逐字(verbatim tail)
4. 重建上下文:系统 + 摘要 + verbatim tail + 最新消息如果软压缩后仍然溢出,触发硬压缩——更激进地截断历史。压缩协议详见 misc/COMPACT.md。
压缩即恢复:任务不中断(2026-09,e33a5b2)
早期实现把压缩当作"迫近溢出时的急救"——而现在它是 agent 长任务的常规换页机制:生成本身到达压缩储备边界时("generation reached compaction reserve"),压缩在消息中间发生,然后循环 continue,任务继续跑。四个配套设计:
- 摘要空间预约。输入尺寸检查(用户消息
agent_worker_append_user、工具结果预留agent_tool_result_reserve_tokens)都以"压缩储备"为下限:储备 = 压缩提示词 token 数 + 摘要预算(ctx/8,钳在 256..4096)+ 64。超限的用户消息被干净地拒绝("user message is too large for --ctx"),绝不追加进当前对话把它挤坏——错误路径不留下半损坏的会话。 - 满会话的恢复。恢复的会话可能已经没有摘要空间。此时摘要只覆盖一个有界前缀(
summary_bottom顶到ctx - 摘要后缀 - 摘要预算 - 1),而所有未被摘要的 token 全部逐字保留进新 tail(trace 里记retained_unsummarized)——宁可摘要短一点,也不丢任何未摘要内容。tail 若从消息中间开始,会插入说明 "[Verbatim tail of the previous conversation; it may begin mid-message.]"。 - tail 边界优先完整用户轮次。
agent_compact_tail_boundary从压缩底线起在2 × tail_budget(ctx/10,上限 50K)范围内向更早的 token 找最近的用户轮次边界——"保留一个稍长的完整轮次"好过"丢了用户的约束、只留我们答案的碎片"。 - 图像块绝不切断。
agent_compact_image_boundary把摘要前缀和 tail 起点都推离图像 token 跨度(GLM 还计 wrapper token)——图像嵌入覆盖的占位行被从中间劈开就无法复用多模态 KV。
未完成回复在同一条 assistant 消息内续写。压缩后不是插入一条合成的"请继续"用户请求(那会让模型跳过未完成的片段或另起任务),而是直接重新附加 assistant 前缀:resumed_start 指向同一条消息,thinking 状态保留(扫描 <think>/</think> 决定用 DS4_THINK_HIGH 还是 DS4_THINK_NONE 前缀),输出预算结转(carried_generation += generated,n_predict 限额跨压缩累计)。生成中撞上边界的半截工具调用则反过来处理:回滚到 assistant_start 删掉残缺参数、压缩后追加系统说明"上次工具调用被压缩中断、未执行,请用更小的参数/编辑重试"——绝不执行残缺参数。
摘要提示词本身也有纪律:"不要在摘要里解决未完成的任务、计算新结果或补完代码;记录用户的要求和实际发生的事,把未完成的工作留作待办"——摘要只做事实记账,不做推演。PTY 回归 tests/test_agent_compaction.py 覆盖真模型压缩、生成代码正确性与预算记账。
会话持久化
agent 会话状态(对话历史、工作目录、压缩标记等)可以保存到 JSON 文件,支持中断后恢复。--session 指定会话文件路径。
非交互模式(--prompt)直接执行单个 prompt 后退出,适合脚本化使用。--cwd 设置工作目录。
ds4_web 浏览器工具
ds4_web.c 提供 google_search 和 visit_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_search 和 visit_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/sprefill_tps 字段记录 done / elapsed 的比值,渲染在进度条的未填充区域。
Web 工具状态消息 ✦:google_search 和 visit_page 执行时显示粉红色的系统状态消息:
✦ Searching Google for "ds4 inference engine"
✦ Opening page https://...agent_publishf_system_status() 是可复用的状态发布函数,所有工具都可以使用。
后台 bash 任务与终端稳定性(2026-09)
bash 从"阻塞到命令结束"升级为可持续观察的后台作业(b36e26e):agent_bash_job 结构带互斥锁与停止截止期;作业在两次工具调用之间保持运行、超时计时也不中断——模型可以发起长命令、隔几轮用 bash_status(参数 job/pid/refresh_sec,续期刷新)看输出、必要时 bash_stop 停掉(agent_bash_signal,SIGKILL 截止期兜底)。这是 agent 从"每步都等"走向"能管住长任务"的关键一步。
文件工具同轮加固:原子替换写入(临时文件 + rename,带元数据与并发修改检查——检测"别的进程在你编辑间隙改了文件")、有界流式读取 + 精确续读(read/more 不再为追大文件爆内存)、[upto] 编辑语义(§edit 工具)。
bash 进程改走 posix_spawn(2026-09,bd66c40):agent_bash_spawn()(setpgroup、/dev/null stdin、dup2 管道)取代 fork/execl。动机是 V4.1 时代的内存量级——推理进程里躺着几百 GiB 的 wired Metal 映射,fork() 虽是写时复制,但复制页表本身就能把内存压垮;posix_spawn 用 vfork 语义的 spawn 接口绕开整份地址空间快照。这是"推理引擎里的子进程"特有的坑:普通 CLI 工具 fork 一下无所谓,带着巨型 GPU 映射的进程不行。
终端侧(db19421,linenoise.c 重写 +465 行):editor_write_async 缓冲完整更新、状态栏重绘不再擦掉正在输入的行、分段 UTF-8 / 粘贴 / Tab 补全安全、Markdown 着色单次更新完成。回归测试 tests/ds4_agent_terminal_test.py + ds4_agent_test.c(+604 行);make test-frontends(42da390)不加载模型跑全部前端回归——agent/server 的解析与渲染测试不该要求先下 90 GiB 权重。
教学提示模式(/hints,2026-09)
/hints on|off(6546ae5,源自教学模式 PR #391 的收敛版)让 agent 在干活的同时顺带解释编程概念:设计取舍、失败机制、验证方法——基于实际看过的代码和结果,每条一两句话,渲染成 Markdown 引用块 > **Hint:**。刻意做成运行期偏好而非持久配置:
agent_hints结构(enabled / instructed / applied 三态)从不写入会话元数据——新会话和恢复的会话一律从 off 开始;没有学习者档案、没有新工具、没有跨会话偏好- 完整指令每个活跃上下文只发一次:首次开启时追加完整的
agent_hints_prompt("偶尔解释当前工作背后的有用概念……宁可不给提示也不要例行叙述、重复或泛泛而谈;不要编造个人经验");之后的开关切换只发一条简短的状态变化说明("Produce them from now on" / "Do not produce teaching asides") - 状态变化在安全的 assistant 边界生效(
worker_run_turn里助手轮开始前追加),不用重建已缓存的上下文;切换前的多次开关合并为最后一次 - 压缩重新确立策略:
agent_hints_compacted()重置instructed(保留的 tail 里可能已没有完整指令),applied 置为 UNKNOWN——恢复会话同理,先用一条"已禁用"说明压过 tail 里残留的旧开启说明,再按用户当前选择走
终端渲染(6289c51):流式 Markdown 渲染器在行首识别 > **Hint:** 标记,渲染成白底青字(white-on-teal)徽章 + 左侧细竖线;显式 > 引用行延续该样式。实现上是个有界前缀保持状态机——只在颜色开启、非代码块、行首等条件下最多暂存 > **Hint:** 那几个字节,普通文本照常立刻流出,没有任何依赖终端宽度的重绘。模型转录、重定向输出保持原样不变。
这个功能本身是个不错的"提示词工程"样本:它不试图在系统提示里塞一个常驻人格,而是把"教学"作为可随时开关的上下文注入,用最少的 token(一次完整指令 + 之后每开关一次一条短说明)换取一种输出风格。
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,常驻 V4.1 Flash(2026-09) | 2–8 session 原生解码;含图像的 session 走有序兜底 |
| Metal RDMA TP,V4.1 Flash(2026-09) | 3–8 session 原生解码;两路有序兜底(两行摊不平批量交换的固定开销) |
| Metal SSD streaming,V4.1 Flash(2026-09) | 有序兜底 |
| Metal,GLM 5.2 | 有序精确兜底 |
| Metal,GLM 5.3 | 可见 token ≤2051 时原生批处理,超出走有序兜底 |
| Metal,Qwen3.8 Flash Next(2026-09) | 原生批处理:只读权重的工作(HC 混合、投影、MoE、输出头)整批算一次;递归状态(GDN)、注意力缓存与 PLE 历史每 session 一份。含图像/steering 的 session 与 >16 session 的推测批走有序兜底 |
| CUDA,Flash(受支持的 TP/EP 多卡布局) | 原生 decode + 混合 prefill/decode,不支持的形状走精确兜底 |
| CUDA,V4.1 Q2 SSD(2026-09) | 最多 8 行 decode 批处理(8-session 测试 11.0 vs 8.4 聚合 t/s);更大的混合 prefill 走有序 |
| 双 Spark 网络 TP,V4.1(2026-09) | ≥5 个就绪 session 才批处理,更小的组有序跑更快 |
| CUDA 单卡(含 DGX Spark,Qwen) | 有序执行(CUDA 按会话顺序 decode) |
N 个常驻 session 分配 N 份 KV 状态——"能装一次的上下文"未必能装 N 次。原生批处理提升聚合吞吐;有序兜底提供并发与公平性,但没有同样加速。原生会话批处理激活时 MTP 推测解码被禁用——唯一例外是 Qwen3.8 on Metal(见下)。
共享 prefill 工作区(2026-09)
模型支持时,各槽共享一个 prefill 工作区而不是各留一份——多一个槽只多它的缓存,不再多几个 GiB 的瞬态缓冲。这对 Qwen3.8 最要紧:它的瞬态按 prefill chunk 而非上下文计尺寸,默认 chunk 下每 session 若干 GiB。引擎把会话瞬态挪进"按最大 session 定尺寸的共享 arena"(同一时刻只允许一个 forward 在跑——Qwen 的批处理因此必须是"多行一次 forward",不能是"多个 forward 共享 arena",否则第二个会覆盖第一个还没被 GPU 读走的瞬态)。启动行会同时报两份内存数字。ctx 4096 下每 session 内存约 ÷3.6。
Qwen 批处理推测解码(batched MTP,2026-09)
Qwen3.8 是第一个把 MTP 带进会话批处理的模型:--batched-session N --mtp 时每个 session 的下一个 token 作第 0 行、预测器留下的草稿搭车作第 1 行,整个批一次 forward 验证;草稿是目标 argmax 就两个 token 一起提交,否则恢复行快照只提交喂入 token(ds4_sessions_eval_batch_speculative_argmax,贪心接受)。服务器把合并的批拆成"普通组 + 贪心推测组"分跑——采样请求(exact sampling + 非零温度)留在普通组,max_tokens 余量 < 2 或 ignore_eos 的请求也不起草。预测器层对整批只跑一遍;预测器失败时整批失效(宁可不推也不留"看似可信实则过期"的状态)。
要不要起草是逐周期、整批同进退的决策:单 session 按置信度开关草稿买不到东西,因为行成本不是线性的——第 17 行会打开每个 dense 投影的第二个 tile,之后行几乎免费。规则是 (1 + p) · c_plain > c_spec(p = 一级接受率 EMA,c = 两种周期的耗时 EMA,各 16 样本封顶窗口);定价没成型前每 32 周期试 8 个推测周期,成型后偶尔探针另一种周期的成本,只有"稳态"周期才计入定价(过渡周期两边都不计)。批宽度变化会重置全部计时。实测 C=16 散文 184→195 t/s(纯 decode ~200)、C=8 散文 131→158 / 代码 131→179——代码(p≈0.94)赚、散文(p≈0.62)微亏,与单会话 MTP 的结论一致(Part 4 §5)。
槽位路由重构:复用探测与陈旧度驱逐(2026-09)
--batched-session N 的路由器长期用 job_slot_score(最长原始公共 token 前缀)选槽,不检查检查点是否真的可复用。平行 subagent 共享一段系统提示时会被吸到主 agent 的长上下文槽上——零复用收益地驱逐它,主 agent 回来还得全量重 prefill(只被 --kv-disk-dir 的磁盘往返勉强遮住);连毫无前缀交集的"外星请求"也会在平局裁决时驱逐 slot[0]。4d7028e + ada805a 两步重构:
1. 复用阶梯探测(slot_probe_reuse_locked)。把每条 RAM 复用路径拆成"廉价探测谓词"与"物化"两层,阶梯与条件只定义一次:responses-visible(Responses 客户端整段重发可见转写,隐藏推理按设计缺席)→ responses/anthropic-tool-output(客户端只发新工具输出,call_id/tool_use_id 集合就是与前序输出的协议绑定——没有可重放前缀,物化失败即 409)→ GLM memory-rewind → memory-token(检查点是 prompt 的精确 token 前缀)→ thinking-visible(工具外思考轮之后,可见转写钉住槽位)→ memory-text(检查点渲染文本是请求文本的字节前缀——覆盖"重发历史重分词不同"的场景)。执行阶梯探测一次、只物化选中的那层;job_slot_score 消费同一个探测——路由与执行不会各说各话。探测永不 tokenize/detokenize(那是物化时才干一次的重活),视觉比较用指纹前缀(ds4_session_vision_fingerprint_prefix_matches:行数+指纹一致即可,位置交给重定基修;5994f06 又给 token/rewind 两层加了历史图像位置精确校验——token 相等证明不了图像没挪过位置)。分数从"检查点全长"改成"实际可复用前缀长"。
2. 陈旧度驱逐分层(ada805a)。零复用请求的受害者选择原先永远是"-live"(驱逐最短检查点)——长跑服务器上"已完成的长对话"永远不是最便宜的受害者,于是堆积、可用槽萎缩、并行请求开始驱逐更短的活跃对话,路由本要修的 prefill 抖动又回来了。新受害者阶梯:空槽恒赢(不用驱逐)→ 闲置超 SLOT_STALE_AFTER_SEC(1 小时)的检查点按最闲置优先、不看长度(被遗忘的会话先回收)→ 更新的检查点视为活跃对话,保持 -live 规则(驱逐最短、损失最少已 prefill 工作)。陈旧度只降低"作为受害者"的优先级,从不阻止复用——陈旧检查点的所有者回来续聊,仍经复用层钉住自己的槽。驱逐在派发时记日志(槽号、被逐 token 数、闲置秒数、stale/protected),路由决策从"下次 cache miss 才能反推"变成日志里直接可见。
配套的会话并发基准 speed-bench/session_concurrency_bench.c + serve_concurrency_bench.py(vLLM 风格 TTFT/ITL/TPOT/E2EL 与 p50–p99,闭合循环与 Poisson 到达两种模式)给这套调度提供可复现测量;网格单元按引擎自身内存记账定价(×1.10 余量),付不起的跳过不硬跑,不完整的流(缺 finish/usage/[DONE]、带错误事件、JSON 破损)整份拒绝。
典型多用户服务部署(与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"的不一致。
7. 多模态服务(图像输入)
2026-09 起 ds4-server 接受内联图像:DeepSeek V4 Flash Vision-Exp 与 GLM 5.3 都支持,V4.1 Flash(Metal)随后加入,Qwen3.8 Flash Next(
qwen38-vision,Metal/CUDA)是第四族(编码器管线详见 Part 3 §7)。服务器启动加--vision gguf/…-Vision-Encoder.gguf;请求侧三套协议都能带图。
API 契约(刻意保守):
| 协议 | 图像字段 | 限制 |
|---|---|---|
| OpenAI Chat | content parts {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}} | 仅 data:image/png|jpeg|jpg;base64, URI |
| OpenAI Responses | input_image 项 | 同上 |
| Anthropic | {"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}} | media_type 仅 png/jpeg/jpg |
- 每请求 ≤ 16 张图、HTTP body ≤ 64 MiB;本地路径与 http(s) URL 一律拒绝(服务器不该替客户端读文件系统/发起外联请求——解析在
parse_image_url_value(),有专门测试锁定拒绝行为) - 每张被接受的图在消息内容里换成一个 64 字节 marker(
server_image_inputs_push_data_uri),tokenize 前由request_tokenize_multimodal_prompt()展开成图像 token 块(ds4_chat_append_multimodal_message把文本 part 与图像交替排列) - DeepSeek 侧要求 Vision-Exp 语言检查点 + 配套 0.9 GiB 编码器;它有自己的 DSpark 检查点(不能混用 0731 的文本版)
V4.1 Flash 视觉(2026-09,Metal)
V4.1 的视觉是第三条管线(ds41f-vision 侧车 GGUF,266 个 BF16 张量逐字节直拷,deepseek41_vision.py;投影 5120 维、图像 token id 129264)。与 V4 Flash Vision-Exp 共享 patch 几何与 ViT 骨架(32 层 / hidden 1024 / 16 头 / patch 14 / 3×3 合并),预处理核心重构成带 v41 开关的同一函数(ds4_image_preprocess_deepseek),但差异实在:
| V4 Flash Vision-Exp | V4.1 Flash | |
|---|---|---|
| token 预算 | 384(硬上限,压缩网格 (H/14+2)/3) | 1024 |
| 最小像素 | 147456 | 295936 |
| 长宽比 | 8:1 钳制 + 偶数行高约束 | 无钳制 |
| token 布局 | 行对交织(列交织重排)+ compress_pad 对齐 | 朴素行主序:START、每行 width×IMAGE + NEWLINE、END,恒等置换 |
内核复用 metal/deepseek4_vision.metal(新增 kernel_deepseek41_vision_bias_residual / _swiglu_split 处理带偏置变体)。服务器侧 server_encode_image 的行缓存宽度改为按 ds4_engine_embd_dim() 取(V4.1 是 5120,不再写死 4096)。图像会话排除在原生批处理解码之外(§6 表)——原生批处理图没有逐行 Engram/图像抑制逻辑。V4 Flash 的视觉编码器与 V4.1 不通用。
多模态 KV 复用的安全边界(98b9135 "reuse exact multimodal live KV safely" + b0982a1 "harden multimodal cache fallback")——图像 token 的 KV 行依赖图像内容,"前缀相同"不再蕴含"KV 可复用":
- 会话记账
checkpoint_images[]:每项 = SHA-256 指纹 + token 跨度;ds4_session_sync_multimodal()校验跨度不重叠(DeepSeek 侧还要求每个跨度恰好一个完整图像块) - 所有 live-KV 复用路径(Responses/Anthropic 续接、GLM rewind、thinking 前缀、纯文本前缀)都要过
ds4_session_vision_state_matches():图像数量、跨度、指纹全部一致才复用;图变了从零重建。GLM rewind 路径还要rewind_valid(公共前缀 == rewind 目标)双确认,否则日志记 "requires rebuild" server_multimodal_resume_pos/frontier把同步起点推进到恰好可复用的 live 前沿——旧实现从 0 开始,会把本可复用的长检查点截断- 磁盘 KV 拒收视觉状态:
kv_cache_store_live_prefix_text()检测ds4_session_has_vision_state()即拒写(磁盘 payload 不带图像身份);恢复纯文本检查点前kv_cache_try_load_text()先清除残留视觉状态;canonicalize_tool_checkpoint对图像请求跳过规范化以保住图像状态
追加图像的 live 前缀复用(2026-09,81a7658)
上一层的语义是"图变了就从零重建"——对追加新图的对话(agent 用 view_image 看了第二张截图、客户端在工具结果里回传新图)太浪费:历史图像的 KV 行没有变。81a7658 把精确匹配细化为前缀匹配 + 图像位置重定基:
- 前缀匹配(
ds4_session_vision_prefix_matches,新导出):历史图像的跨度与指纹全部一致、新图像的起点在 live token 前沿之后即可复用;"无效检查点永不匹配"也从兜底默认改成了硬规则。ds4_session_sync只在检查点有效时才做这个校验 - 位置重定基(
ds4_session_rebase_vision_state,新 API):live 续接由独立的凭证认证(如工具调用 ID 匹配)后,从检查点恢复各图像的 token 起点——逐项核对指纹与行数,失败则原样不动。它不校验文本历史,那是续接凭证的职责 - 图像 token 拼接(
build_live_prompt_suffix):四条 live 续接路径(纯文本前缀、Responses/Anthropic 工具输出续接、可见前缀续接)统一改走它——复制 live token,文本部分用ds4_tokenize_rendered_chat只 tokenize 请求新增字节(完整 BPE 可能跨边界合并,见 §3d),新图像的 token 块原样 push 进去并更新跨度;最终再过一次前缀匹配才放行 - 可见历史的 marker 归一化(
visible_prompt_key):每张图的 64 字节 marker 含请求局部 nonce,跨请求比对可见文本时把 marker 跨度替换成\036并记录字节偏移——字面文本伪装不成图像(偏移来自真实 marker 跨度),复用前还有完整指纹校验兜底 - 有界嵌入缓存(
server_image_cache):32 项 / 128 MiB 的 LRU,键是编码后图像的精确字节(不信任客户端提供的哈希),inference_mu保护、只缓存成功编码、调用方持有副本(DeepSeek 会原地展开自然嵌入)——同一张图反复出现(重试、多轮引用)不再重复跑 ViT
图像工具结果(如 view_image 的输出)现在在三套 API(Chat Completions / Responses / Anthropic)里都能往返。M5 Max 上以 GLM 5.3 Flash 与 DeepSeek Vision-Exp 验证:53K/56K token 前缀、批处理会话、三格式、全新 logit 回放、前端 ASan/UBSan。配套回归:test_server_vision_cache.py(前缀与编码器缓存)、test_server_vision_agent.py(真模型图像回放 + Pi 编码客户端)、test_session_state.c。
这套设计与 KV 持久化 的精神一致:KV 状态是最高保真度的资产,复用必须以可验证的同一性(token 序列 + 图像指纹)为前提;前缀复用放松的是"数量必须相等",没有放松"身份必须可证"。
Qwen3.8 视觉与图像位置校验(2026-09)
Qwen3.8 的视觉走 llama.cpp 的 Qwen3-VL mmproj(qwen38-vision 下载 llama.cpp 官方 Q8_0 转换,clip + qwen3vl_merger 投影)——ds4 第一次直接消费 llama.cpp 生态的标准工件。图像缩放到 32 像素倍数、token 预算 64..1024(DS4_QWEN4_IMAGE_MAX_TOKENS 提上限),GPU 编码,视觉 token 取模型的 3D rope 位置(t/h/w 三轴,与文本位置表同源);live KV 复用以图像指纹为键。5994f06 随后把 §6 复用阶梯的视觉校验收紧:memory-token/memory-rewind 两层此前只比 token 前缀——图像跨度挪了位置的请求能造成假命中(token 相等认证不了一张挪过的图,这两层不做跨度重定基);现在这两层先过 ds4_session_vision_prefix_matches 精确校验历史图像位置,可见/续接层保持指纹前缀匹配(它们经 build_live_prompt_suffix 重定基)。
JPEG 渐进扫描 ZRL 修复(ac43e90,third_party/iris):AC 精化扫描里 ZRL(run=15、size=0)被当成 run=16 复用了跳过循环——那个循环会给路过的每个非零历史系数消耗一个修正 bit,第 16 个零之后还在消耗属于下一个 Huffman 符号的 bit(ITU T.81 G.1.2.3;libjpeg 的 decode_mcu_AC_refine 到 16 就停),位流从此失步:很多渐进 JPEG(包括朴素 cjpeg -progressive 输出)加载失败或解码出错误像素。修复按 libjpeg 结构重写(跳 run 个零、顺路精化非零、在下一个零历史系数处写新值后停),配 tests/test_image_decode + 两个 cjpeg 夹具,与 djpeg 逐位一致。事故教训同 Part 5 §3h:媒体解析器的每个分支都要对着规范参考实现逐字节对齐,"能解码多数图片"不等于对。
ds4-agent 侧的多模态与终端修复
--vision同时给 agent 启用view_image工具(读图 → 编码 → 描述);ROCm 构建下 agent 接受--rocm后端(ab993cb,错误信息列表同步 "metal, rocm, cpu")- 流式终端折行修复(
04478e0):agent TUI 用 DECSC/DECRC(ESC 7/ESC 8)保存/恢复光标,但这对序列不保留自动换行 pending 标志——恰好停在右边距的 chunk 会推迟到下一段内容到达才显示,列 80 边界丢字/重字。新助手editor_write_scroll_output_preserve_prompt()+ 回归测试test_agent_terminal_wrap_output_is_deferred()