mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
perf(api): SSE 分块 C 导航层 + 差分等价验收(实测更慢 ⇒ 默认关闭)
第三刀:把 ha_json_scan 接进 parseOpenAICompatibleStreamChunkFull。
**结论是否定的** —— 实测比原实现慢,故默认关闭并如实记录。这条提交的
价值在于「已钉死的正确性 + 已定位的根因 + 一条防静默回退的断言」。
## 设计:只做「结构导航」,序列化留在 Go
接线前实测出两条 wire 语义,它们让「整条解析全 C 化」不成立:
§5.1 重复键是**字段级合并**,不是替换:
{"choices":[{content:a}],"choices":[{reasoning:r}]} → 两个都保留。
机制:json.Unmarshal 的 object() 收尾做 v.SetIndex(i, subv.v),
而 subv 拿到的是**已存在元素的指针** ⇒ 第二次是叠加。
§5.2 stringifyContent 的 default 分支 = json.Marshal(interface{}),
即**重新序列化**:{"b":1,"a":2}→{"a":2,"b":1}(键排序)、
1e2→100、<→\u003c、大 int 先舍入成 float64。
逐值一致 = 复刻 Ryu 最短浮点 + map 键排序 + HTML 转义 + int 舍入。
两条都只在**取值**阶段需要,故 C 只回答「值在哪里」(零分配零解码),
类型检查靠「用相同的 Go 类型 unmarshal 相同形状的子树」保证,不靠 C 复刻规则。
## 实测:新路径比原实现慢(20000 次迭代)
| 场景 | 新路径 | 原实现 |
|---|---|---|
| content_ascii | 2016ns / 20allocs | 1325ns / 13allocs |
| toolcall | 5854ns / 33allocs | 3270ns / 21allocs |
| usage | 3170ns / 24allocs | 2832ns / 12allocs |
分配数**也变多**(20 vs 13),与「消除 GC 抖动」的初衷相反。
根因(逐项测量,非猜测):裸 cgo 调用 168ns;**每次带 out-param 的键查找
205ns + 2 allocs**(out-param 逃逸到堆);一次解析需要 5+ 次查找
⇒ 边界与分配成本约 1µs,恰好吃掉全部收益。Go 侧只需**一次** Unmarshal。
一句话:**用很多次廉价调用换一次昂贵调用,在这个尺寸上不划算。**
## 天花板实验:方向对,但当前实现没到
假设拿到 span 完全免费,只测设计中必须由 Go 做的部分:
我的 Go 侧 505ns/7allocs vs 原实现 1239ns/13allocs
⇒ 边界归零后仍有 2.4× 时间、46% 分配的空间。故问题在**逐字段往返**
这个交互方式,不在 C 本身。正确改造:一次调用返回全部字段 span +
结果写调用方栈结构体 + 仅在确需重新编码时回退。
## 正确性:6 万+ 差分用例全过
同一批输入跑两条路径逐字段比对(Content/Reasoning/Done/Finish/ToolCalls/
Usage + bool),5 组:协议形态(含全部回退触发条件)、真实负载、随机 JSON
30000 例、随机字节 30000 例、优化有效性。
★ 差分测试当场抓出 4 个真实缺陷(其中一个正是「优化压根没生效」):
1. ha_sse_arr_first 里「重新 init 到 sc.s+sc.i」使 base 变了 ⇒ start 恒 0
⇒ 返回的是**数组本身**而非首元素。症状是**快速路径永远不生效**——
而若只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是对的)。
⇒ 这就是必须单独断言「优化确实被走到」的原因。
2. chunkAssemble 的 bool 被丢弃 ⇒ 空对象被判 true(原实现 false)
3. 键匹配层级搞错:delta 是 **struct**(字段名 CI),不是 map。
我一度「推理」成 CS 并以为差分测试会通过——错的。
教教训:哪层是 struct、哪层是 map 要**回原实现读类型**,不能凭字段名推断。
4. cgo 边界:out-param 逃逸到堆
另修:C 代码从 cgo 前言移进 csrc/ ——前言里的 C **逃出全部 C 门禁**
(告警/sanitizer/交叉/模糊测试),而它恰是本刀最易出错处。
## 防静默回退
TestChunkFast_BenchGate 断言 chunkFastEnabled 必须为 false。
后来者看到「快速路径写得全 + 差分测试全过」,很自然会以为它已生效并打开它
—— 而实测更慢。断言把这个事实钉住,改动即判红。
## 实测汇总
- C 契约 119 项断言、黄金对照 5 组、差分 6 万+ 例:全过
- ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警(3 个源文件)
- 全量 go test -count=1 ./... 38 包 ok / 0 FAIL
- libFuzzer 4948 万次零崩溃(上一刀)
教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
第一刀被 C.CString 的 82% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
This commit is contained in:
138
csrc/include/ha_sse.h
Normal file
138
csrc/include/ha_sse.h
Normal file
@ -0,0 +1,138 @@
|
||||
#ifndef HA_SSE_H
|
||||
#define HA_SSE_H
|
||||
|
||||
/*
|
||||
* ha_sse — LLM 流式协议(SSE 分块)的「结构导航」辅助层
|
||||
*
|
||||
* ============================ 定位 ============================
|
||||
* 本层不是 JSON 库(那是 ha_json_scan),而是把 ha_json_scan 原语组合成
|
||||
* **协议层需要的几次定位**,供内核 parseOpenAICompatibleStreamChunkFull 使用。
|
||||
*
|
||||
* ★ 为什么这些函数放在 csrc/ 而不是内联在 Go 的 cgo 前言里:
|
||||
* 放在 cgo 前言里的 C 代码**逃出了全部 C 门禁**(告警 / ASan+UBSan /
|
||||
* 交叉编译 / 模糊测试),而它恰恰是本刀最容易出错的位置。
|
||||
* 移进 csrc/ 后,同一个 -Wall -Wextra -Wpedantic -Wconversion 门禁
|
||||
* 与 sanitizer 都覆盖到它 —— 这是一次真实的结构调整,不是形式主义。
|
||||
*
|
||||
* ============================ 为什么只做「导航」 ============================
|
||||
* 实测两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5)使「全量 C 化」不成立:
|
||||
* §5.1 重复键是**字段级合并**(json.Unmarshal 的 SetIndex 叠加语义)
|
||||
* §5.2 stringifyContent 的 default 分支是 json.Marshal(键排序 / 浮点
|
||||
* 最短往返 / HTML 转义 / int 舍入)
|
||||
* 二者都只在**取值**阶段需要,故本层只回答「值在哪里、它的热分支结果是什么」,
|
||||
* 需要重新序列化的形态交回 Go(由 encoding/json 保证语义)。
|
||||
*
|
||||
* ============================ 键匹配:大小写敏感 ============================
|
||||
* 本层是 **map key** 语义(`m["text"]`)⇒ 大小写敏感。
|
||||
* 实测 `{"TEXT":"up"}` 取不到 `text`、`{"text":"low"}` 可以(§5.4-1)。
|
||||
*
|
||||
* ⚠️ 与 ha_json_key_eq(大小写**不**敏感,用于 struct 字段名)语义相反。
|
||||
* 两者用途不同、都必要,**不要「统一」掉**。
|
||||
* struct 字段那一跳由 encoding/json 负责,天然正确。
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "ha_abi.h"
|
||||
#include "ha_json_scan.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define HA_SSE_ABI_MAJOR 1
|
||||
#define HA_SSE_ABI_MINOR 0
|
||||
#define HA_SSE_ABI_VERSION (HA_SSE_ABI_MAJOR * 1000 + HA_SSE_ABI_MINOR)
|
||||
|
||||
HA_STATIC_ASSERT(HA_SSE_ABI_MAJOR >= 1 && HA_SSE_ABI_MAJOR <= 9,
|
||||
ha_sse_abi_major_in_range);
|
||||
HA_STATIC_ASSERT(HA_SSE_ABI_MINOR >= 0 && HA_SSE_ABI_MINOR <= 99,
|
||||
ha_sse_abi_minor_in_range);
|
||||
|
||||
int ha_sse_abi_version(void);
|
||||
|
||||
/*
|
||||
* 在对象里按**大小写敏感**的键定位值。
|
||||
*
|
||||
* 返回: 1 = 找到(*out 已写);0 = 未找到(对象良构);-1 = 对象畸形。
|
||||
* *dup 在发现**重复键**时置 1(后者胜已写入 *out)——
|
||||
* 调用方据此整体回退到 encoding/json,因为重复键的字段级合并语义
|
||||
* 见 sse-codec-c.md §5.1,本层不实现。
|
||||
*/
|
||||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
||||
ha_span *out, int *dup);
|
||||
|
||||
/*
|
||||
* 在对象里按**大小写不敏感**的键定位值(struct 字段语义)。
|
||||
*
|
||||
* ★ 为什么必须与 ha_sse_obj_find 并存(两个函数,语义相反):
|
||||
* - Go 的 `raw struct{ Choices ... \`json:"choices"\` }` 是 **struct 字段**,
|
||||
* encoding/json 对字段名做**大小写不敏感**匹配 ⇒ 实测
|
||||
* `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 能取到 content="ci"。
|
||||
* - 而 `content` 是 `interface{}` → `map[string]interface{}`,取 `m["text"]`
|
||||
* 是 **map key** 语义 ⇒ 大小写**敏感**(实测 `{"TEXT":"up"}` 取不到)。
|
||||
* 跳错层就会静默漏掉字段(或取到不该取的),故两个函数都必要,
|
||||
* 调用方必须按「这一跳在 Go 里是 struct 还是 map」来选择。
|
||||
*
|
||||
* 返回与 ha_sse_obj_find 相同:1=找到 0=未找到 -1=畸形。
|
||||
* 对 *dup:大小写不敏感语义下,`{"CHOICES":..,"choices":..}` 两次都会命中
|
||||
* 同一个 Go 字段(后者胜),故同样置 dup 让调用方回退。
|
||||
*/
|
||||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
||||
ha_span *out, int *dup);
|
||||
|
||||
/*
|
||||
* 校验 doc 是「**恰好一个**良构 JSON 对象」(尾部只允许空白)。
|
||||
*
|
||||
* 返回 1 = 是;0 = 否。
|
||||
*
|
||||
* ★ 为什么必须单独校验尾部:ha_sse_obj_find 用 members_complete 只保证
|
||||
* 对象本身闭合,**不检查尾部残留** —— 而 Go 的 json.Unmarshal 会拒绝
|
||||
* `{"a":1}{"b":2}`(trailing garbage)。少了这一步,快速路径会比 Go 宽松,
|
||||
* 把一个 Go 判为失败的块判为成功 ⇒ 静默接受垃圾块。
|
||||
*/
|
||||
int ha_sse_root_object(const ha_span *doc);
|
||||
|
||||
/*
|
||||
* 取数组**第一个元素**的 span。
|
||||
*
|
||||
* 返回: 1 = 有元素;0 = 空数组;-1 = 非数组或畸形。
|
||||
*
|
||||
* 为什么只要第一个:`choices[0]` 是协议约定(Go 侧也只读 resp.Choices[0]),
|
||||
* 本层据此避免为后续元素做无用功。
|
||||
*/
|
||||
int ha_sse_arr_first(const ha_span *arr, ha_span *out);
|
||||
|
||||
/*
|
||||
* stringifyContent 的 **C 可判定分支**:
|
||||
* - 字符串值 → 反转义后原样输出
|
||||
* - 数组值 → 逐元素取对象的 "text" 字段(精确键)拼接
|
||||
*
|
||||
* 返回: 1 = 已写入(*outlen 为字节数);0 = 需回退 Go。
|
||||
* 回退的两种情形:
|
||||
* a) 缓冲不足(调用方应给 >= val->len*3+4 的 cap)
|
||||
* b) 值类型是对象 / 数字 / 字面量 —— 那些要走 json.Marshal(§5.2)
|
||||
*
|
||||
* 数组元素的规则(§5.4-2/3 实测):
|
||||
* · 非对象元素 **静默跳过**(`["a",{"text":"b"}]` → "b")
|
||||
* · 非对象的 "text"(如 text:123)**静默跳过**
|
||||
* · 元素里出现重复的 "text" 键 ⇒ 整体回退 Go(合并语义)
|
||||
*/
|
||||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
||||
|
||||
/*
|
||||
* arguments 为**字符串**时,取出其解码结果(省掉 interface{} 与二次解析)。
|
||||
*
|
||||
* 返回 1 = 已写入;0 = 不是字符串或失败(调用方按既有路径处理)。
|
||||
* 非字符串 arguments(对象/数组/数字)**必须**回退 Go:那里的
|
||||
* `rawArgsString` 会 `json.Marshal` 重新编码,而这个**重新编码的键序
|
||||
* 可能与原文不同**(实测 {"b":2,"a":1} → {"a":1,"b":2})——
|
||||
* 逐值一致要求由 encoding/json 来做。
|
||||
*/
|
||||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_SSE_H */
|
||||
280
csrc/src/ha_sse.c
Normal file
280
csrc/src/ha_sse.c
Normal file
@ -0,0 +1,280 @@
|
||||
/*
|
||||
* ha_sse.c — LLM 流式协议(SSE 分块)结构导航辅助层
|
||||
*
|
||||
* 语义与理由见 include/ha_sse.h。本文件被 C 门禁全量覆盖
|
||||
* (告警 / ASan+UBSan / arm64 交叉编译 / libFuzzer),故**不放**在
|
||||
* Go 的 cgo 前言里 —— 前言里的 C 代码逃出全部检查。
|
||||
*/
|
||||
|
||||
#include "ha_sse.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
int ha_sse_abi_version(void) {
|
||||
return HA_SSE_ABI_VERSION;
|
||||
}
|
||||
|
||||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
||||
ha_span *out, int *dup) {
|
||||
ha_json_members m;
|
||||
ha_span k, v;
|
||||
int hit = 0;
|
||||
|
||||
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
|
||||
return 0;
|
||||
}
|
||||
*dup = 0;
|
||||
out->p = NULL;
|
||||
out->len = 0;
|
||||
if (keylen == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!ha_json_members_init(&m, obj->p, obj->len)) {
|
||||
return -1;
|
||||
}
|
||||
while (ha_json_members_next(&m, &k, &v)) {
|
||||
/* ★ 精确比较(不做大小写折叠):与 Go 的 map key 语义一致。
|
||||
* §5.4-1 实测 {"TEXT":"up"} 取不到 text。 */
|
||||
if (k.len == keylen && memcmp(k.p, key, keylen) == 0) {
|
||||
if (hit) {
|
||||
*dup = 1; /* 重复键:调用方整体回退 Go */
|
||||
}
|
||||
hit = 1;
|
||||
*out = v; /* 后者胜 */
|
||||
}
|
||||
}
|
||||
if (!ha_json_members_complete(&m)) {
|
||||
return -1; /* 对象畸形 */
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/* 单字节 ASCII 小写折叠(非 ASCII 原样,与 Go 对 ASCII 字段名的行为一致)。 */
|
||||
static unsigned char sse_lower(unsigned char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
||||
}
|
||||
|
||||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
||||
ha_span *out, int *dup) {
|
||||
ha_json_members m;
|
||||
ha_span k, v;
|
||||
int hit = 0;
|
||||
|
||||
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
|
||||
return 0;
|
||||
}
|
||||
*dup = 0;
|
||||
out->p = NULL;
|
||||
out->len = 0;
|
||||
if (keylen == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!ha_json_members_init(&m, obj->p, obj->len)) {
|
||||
return -1;
|
||||
}
|
||||
while (ha_json_members_next(&m, &k, &v)) {
|
||||
if (k.len == keylen) {
|
||||
size_t j = 0;
|
||||
while (j < keylen &&
|
||||
sse_lower((unsigned char)k.p[j]) ==
|
||||
sse_lower((unsigned char)key[j])) {
|
||||
j++;
|
||||
}
|
||||
if (j == keylen) {
|
||||
if (hit) {
|
||||
*dup = 1;
|
||||
}
|
||||
hit = 1;
|
||||
*out = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ha_json_members_complete(&m)) {
|
||||
return -1;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
int ha_sse_root_object(const ha_span *doc) {
|
||||
ha_json_scan sc;
|
||||
|
||||
if (doc == NULL || doc->p == NULL || doc->len == 0) {
|
||||
return 0;
|
||||
}
|
||||
ha_json_scan_init(&sc, doc->p, doc->len);
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '{') {
|
||||
return 0; /* 顶层非对象:Go 的 Unmarshal 进 struct 会失败 */
|
||||
}
|
||||
if (!ha_json_skip(&sc)) {
|
||||
return 0;
|
||||
}
|
||||
/* 尾部只允许空白 —— 复刻 json.Unmarshal 对 trailing garbage 的拒绝 */
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
return ha_json_scan_eof(&sc) ? 1 : 0;
|
||||
}
|
||||
|
||||
int ha_sse_arr_first(const ha_span *arr, ha_span *out) {
|
||||
ha_json_scan sc;
|
||||
size_t start;
|
||||
|
||||
if (arr == NULL || out == NULL) {
|
||||
return 0;
|
||||
}
|
||||
out->p = NULL;
|
||||
out->len = 0;
|
||||
if (arr->p == NULL || arr->len == 0) {
|
||||
return 0;
|
||||
}
|
||||
/* ★ 游标的 base 始终是 arr->p,中途只推进 i。
|
||||
*
|
||||
* 初版在这里犯过一个「重新 init 到 sc.s + sc.i」的错:那样 base 变了,
|
||||
* 随后的 start = sc.i 变成 0,out->p = arr->p + 0 ⇒ **返回的是数组本身**
|
||||
* 而不是第一个元素。症状是上层的 fastChoice 拿到 firstByte=='[' 直接回退,
|
||||
* 表现为「快速路径永远不生效」——
|
||||
* 而如果只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是正确)。
|
||||
*
|
||||
* ★ 这正是「优化是否真的生效」必须单独断言的原因:
|
||||
* 等价性测试无法发现「一直回退」。
|
||||
*/
|
||||
ha_json_scan_init(&sc, arr->p, arr->len);
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
|
||||
return -1;
|
||||
}
|
||||
sc.i++; /* 跳过 '[' */
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
||||
return 0; /* 空数组 */
|
||||
}
|
||||
start = sc.i;
|
||||
if (!ha_json_skip(&sc)) {
|
||||
return -1;
|
||||
}
|
||||
out->p = arr->p + start;
|
||||
out->len = sc.i - start;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen) {
|
||||
size_t len = 0;
|
||||
ha_json_scan sc;
|
||||
ha_span raw;
|
||||
|
||||
if (val == NULL || out == NULL || outlen == NULL ||
|
||||
val->p == NULL || val->len == 0) {
|
||||
return 0;
|
||||
}
|
||||
*outlen = 0;
|
||||
/* 上界:每个输入字节最坏变 3 字节 U+FFFD。不足则交回 Go 走
|
||||
* json.Unmarshal(宁可慢也不截断)。 */
|
||||
if (cap < val->len * 3u + 4u) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (val->p[0] == '"') {
|
||||
ha_json_scan_init(&sc, val->p, val->len);
|
||||
if (!ha_json_scan_string(&sc, &raw)) {
|
||||
return 0;
|
||||
}
|
||||
{
|
||||
size_t n = ha_json_decode_string_into(raw, out, cap);
|
||||
if (n == (size_t)-1) {
|
||||
return 0;
|
||||
}
|
||||
*outlen = n;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (val->p[0] == '[') {
|
||||
ha_json_scan_init(&sc, val->p, val->len);
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
sc.i++; /* 跳过 '[' */
|
||||
for (;;) {
|
||||
size_t start;
|
||||
ha_span elem;
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
||||
break;
|
||||
}
|
||||
start = sc.i;
|
||||
if (!ha_json_skip(&sc)) {
|
||||
return 0;
|
||||
}
|
||||
elem.p = val->p + start;
|
||||
elem.len = sc.i - start;
|
||||
|
||||
/* 只有对象元素才可能有 text(§5.4-2:其余静默跳过) */
|
||||
if (elem.len > 0 && elem.p[0] == '{') {
|
||||
ha_span txt;
|
||||
int dup = 0;
|
||||
int rc = ha_sse_obj_find(&elem, "text", 4, &txt, &dup);
|
||||
if (dup) {
|
||||
return 0; /* 重复 text 键 ⇒ 交回 Go(合并语义) */
|
||||
}
|
||||
/* 只有字符串形态的 text 才取(§5.4-3) */
|
||||
if (rc == 1 && txt.len > 0 && txt.p[0] == '"') {
|
||||
ha_json_scan ts;
|
||||
ha_span traw;
|
||||
size_t n;
|
||||
ha_json_scan_init(&ts, txt.p, txt.len);
|
||||
if (!ha_json_scan_string(&ts, &traw)) {
|
||||
return 0;
|
||||
}
|
||||
/* cap-len 已保证至少 1 字节可用(含结尾 NUL) */
|
||||
n = ha_json_decode_string_into(traw, out + len, cap - len);
|
||||
if (n == (size_t)-1) {
|
||||
return 0;
|
||||
}
|
||||
len += n;
|
||||
}
|
||||
}
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc)) {
|
||||
break;
|
||||
}
|
||||
if (sc.s[sc.i] == ',') {
|
||||
sc.i++;
|
||||
continue;
|
||||
}
|
||||
if (sc.s[sc.i] == ']') {
|
||||
break;
|
||||
}
|
||||
return 0; /* 畸形数组 */
|
||||
}
|
||||
*outlen = len;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 对象 / 数字 / true / false / null ⇒ 需 json.Marshal 重新编码(§5.2) */
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen) {
|
||||
ha_json_scan sc;
|
||||
ha_span raw;
|
||||
size_t n;
|
||||
|
||||
if (val == NULL || out == NULL || outlen == NULL ||
|
||||
val->p == NULL || val->len == 0) {
|
||||
return 0;
|
||||
}
|
||||
*outlen = 0;
|
||||
if (val->p[0] != '"') {
|
||||
return 0; /* 非字符串:交回 Go(需 json.Marshal 重新编码) */
|
||||
}
|
||||
if (cap < val->len * 3u + 4u) {
|
||||
return 0;
|
||||
}
|
||||
ha_json_scan_init(&sc, val->p, val->len);
|
||||
if (!ha_json_scan_string(&sc, &raw)) {
|
||||
return 0;
|
||||
}
|
||||
n = ha_json_decode_string_into(raw, out, cap);
|
||||
if (n == (size_t)-1) {
|
||||
return 0;
|
||||
}
|
||||
*outlen = n;
|
||||
return 1;
|
||||
}
|
||||
@ -206,6 +206,154 @@ C 实现不是「重新设计」,是**逐值复刻 Go**。而 Go 的 `encoding
|
||||
内容**静默变成 `?0?d?d?0`**(那正是 SDK `ha_json.c` 的缺陷 1 的形态)。
|
||||
代价是约 10 行代码 + 一组已通过的测试。
|
||||
|
||||
## 五、接线前探明的六个**语义**(决定「C 化到什么程度」)
|
||||
|
||||
第二刀把库验完后,接线前又探了一轮 wire 语义。其中两条**直接推翻了
|
||||
「整条 parseOpenAICompatibleStreamChunkFull 全 C 化」的设想**。
|
||||
|
||||
### 5.1 重复键是**字段级合并**(`json.Unmarshal` 的数组语义)
|
||||
|
||||
```
|
||||
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{"reasoning_content":"r"}}]}
|
||||
→ content="a" reasoning="r" ← 两个都保留!
|
||||
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{}}]}
|
||||
→ content="a" ← 第二次是空 delta,也没把 content 清掉
|
||||
{"usage":{"prompt_tokens":1},"usage":{"completion_tokens":2}}
|
||||
→ usage={1,2,0} ← 字段级合并
|
||||
```
|
||||
|
||||
**机制**:`d.saveError(&d.array)` 保存目标;`object()` 收尾时执行
|
||||
`v.SetIndex(i, subv.v)`,而 subv 解析时拿到的是**已存在元素的指针**
|
||||
⇒ 第二次 unmarshal 是**叠加**在第一次之上的,不是替换。
|
||||
|
||||
⇒ 「第二个 element 整体覆盖第一个」是**错的**。正确实现需要维护
|
||||
**「本次哪些字段出现过」的** 逐字段表**。这能做,但要显式建模。
|
||||
|
||||
### 5.2 `stringifyContent` 的默认分支 = `json.Marshal(interface{})`(**再编码**)
|
||||
|
||||
这是最关键的一条。`content` 是 `interface{}`,落到 default 分支时
|
||||
**重新序列化一遍**:
|
||||
|
||||
| content 输入 | stringifyContent 输出 |
|
||||
|---|---|
|
||||
| `{"b":1,"a":2}` | `{"a":2,"b":1}`(**键排序**) |
|
||||
| `{"k":"<a>&b"}` | `{"k":"\u003ca\u003e\u0026b"}`(**HTML 转义**) |
|
||||
| `1e2` | `100`(float64 归一) |
|
||||
| `1.0` | `1` |
|
||||
| `123456789012345678` | `123456789012345680`(float64 舍入) |
|
||||
| `1e21` | `1e+21` |
|
||||
|
||||
要让 C 版与 Go 逐值一致,就必须复刻 Go 的:
|
||||
① 浮点**最短往返**格式化(`strconv.AppendFloat` 的 Ryu 语义,位数随值变化)
|
||||
② `map` **按键排序**(Go 的 map 无序 ⇒ 排序是 Marshal 的确定性来源)
|
||||
③ 字符串的 **HTML 转义 + `
/
` 转义**
|
||||
④ int → **float64 舍入**再格式化
|
||||
|
||||
这不是「顺手写一下」的量级,而是一整套序列化器 + 一个浮点格式化器。
|
||||
|
||||
### 5.3 结论:C 化**降级**为「结构导航」层,序列化留在 Go
|
||||
|
||||
本条不是为了少做事,而是因为上面两条决定了一个可检验的事实:
|
||||
|
||||
> **C 负责把 JSON 定位到「哪个值在哪里」(零分配、零解码);
|
||||
> Go 负责把「已定位的原始字节」变成 `interface{}`(`json.Unmarshal`),
|
||||
> 再按既有逻辑变成字符串。**
|
||||
|
||||
C 层因此**无需**理解重复键的合并语义(5.1)、**无需**实现浮点格式化
|
||||
与键排序(5.2)—— 它只回答「`choices[0].delta.content` 的 span 在哪」。
|
||||
|
||||
代价与收益(如实记录):
|
||||
|
||||
| | 收益 | 代价 |
|
||||
|---|---|---|
|
||||
| C 定位 | 免除 `json.Unmarshal` 的**反射建树**(每块 12~21 allocs 的主因) | 命中字段仍要一次小 `Unmarshal` |
|
||||
| 保留 Go 序列化 | 5.1/5.2 的语义**逐字**保持,不需要两套实现 | 值转换仍有少量 alloc |
|
||||
|
||||
**唯一例外(已实测可达)**:`arguments` 若 upstream 发的是**非字符串**
|
||||
对象/数组,Go 侧会 `json.Marshal` 重新编码(`{"b":2,"a":1}` → `{"a":1,"b":2}`),
|
||||
**重新编码的键序可能与原文不同**。这类值必须走 Go(见接线实现的注释)。
|
||||
|
||||
### 5.4 其余四条语义(接线时直接照做即可)
|
||||
|
||||
| # | 语义 | 实测 |
|
||||
|---|---|---|
|
||||
| 1 | **key 大小写敏感**(map key) | `{"TEXT":"up"}` 取不到 `text`;但 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 有效(struct 字段名不敏感) |
|
||||
| 2 | `content` 数组:非对象元素**静默跳过** | `["a",{"text":"b"}]` → `"b"` |
|
||||
| 3 | `content` 数组:`text` 非字符串**静默跳过** | `[{"text":123},{"text":"b"}]` → `"b"` |
|
||||
| 4 | `index` 非整数 ⇒ **整块作废** | `{"index":1.5}` → false |
|
||||
| 5 | usage 的 cache 字段类型错也**让整块作废** | `{"prompt_cache_hit_tokens":"x","prompt_tokens":1}` → false |
|
||||
|
||||
第 1 条与 ha_json_scan 的 `ha_json_key_eq`(大小写不敏感)**语义相反**,
|
||||
两者用途不同、互不冲突(见 §5.3)—— 但必须在代码里注明,否则后人会「统一」掉。
|
||||
|
||||
## 六、接线实测:**本架构比原实现慢**(诚实记录,已默认关闭)
|
||||
|
||||
第三刀把 `ha_json_scan` 接进了生产路径(`chunkParseFast`),
|
||||
**6 万+ 差分用例证明它与原实现逐值等价**(含语法、成员、解码、整数、
|
||||
随机 JSON、随机字节五组)。但基准给出了**否定结论**,故**默认关闭**。
|
||||
|
||||
### 6.1 实测对比(`codec_chunkfast_bench_test.go`,20000 次迭代)
|
||||
|
||||
| 场景 | 新路径(Entry) | 原实现(GoOnly) | 结论 |
|
||||
|---|---:|---:|---|
|
||||
| content_zh | 2245 ns / 20 allocs | 2038 ns / 13 allocs | 更慢 |
|
||||
| content_ascii | **2016 ns / 20 allocs** | **1325 ns / 13 allocs** | 慢 52% |
|
||||
| toolcall | **5854 ns / 33 allocs** | **3270 ns / 21 allocs** | 慢 79% |
|
||||
| usage | 3170 ns / 24 allocs | 2832 ns / 12 allocs | 更慢 |
|
||||
| finish | 1560 ns / 20 allocs | 1098 ns / 12 allocs | 更慢 |
|
||||
|
||||
分配数**也变多**(20 vs 13)—— 与本刀「消除 GC 抖动」的初衷相反。
|
||||
|
||||
### 6.2 根因(逐项测出来的,不是猜的)
|
||||
|
||||
| 测量 | 数值 | 含义 |
|
||||
|---|---:|---|
|
||||
| 裸 cgo 调用(无 out-param) | **168 ns** | 一次性边界成本 |
|
||||
| 带 out-param 的键查找 | **205 ns / 2 allocs** | 边界 + out-param 逃逸到堆 |
|
||||
| 一次解析需要的键查找次数 | **5+** | choices→[0]→delta→content/reasoning/tool_calls→finish_reason |
|
||||
|
||||
⇒ **5 × 205ns ≈ 1µs 的边界与分配成本,恰好把收益全部吃掉。**
|
||||
而 Go 侧是**一次** `json.Unmarshal` 遍历建整棵树。
|
||||
|
||||
**根因一句话**:本架构是「用很多次廉价调用,换一次昂贵调用」——
|
||||
在这个尺寸上不划算。逐字段往返是设计错误,不是实现调优能救的。
|
||||
|
||||
### 6.3 天花板实验:方向对,但当前实现没到
|
||||
|
||||
为判断「还值不值得改」,我测了一个假设性上界 —— **假设拿到 span 完全免费**
|
||||
(span 预先算好),只测本设计中**必须由 Go 做**的那部分:
|
||||
|
||||
| | ns/op | allocs |
|
||||
|---|---:|---:|
|
||||
| 我设计里的 Go 侧工作(零边界成本) | **505** | **7** |
|
||||
| 原实现(整块 json.Unmarshal) | 1239 | 13 |
|
||||
|
||||
⇒ 若边界成本能压到近零,**仍有 2.4× 时间与 46% 分配的空间**。
|
||||
故这不是「C 化没意义」,而是「**逐字段往返**这个交互方式是错的」。
|
||||
|
||||
### 6.4 正确的下一步(已由实测指明)
|
||||
|
||||
改造方向不是调优现有代码,而是**减少跨界次数**:
|
||||
|
||||
1. **一次 C 调用返回全部字段的 span**(批量),而不是逐字段往返
|
||||
—— 把 5+ 次边界压成 1 次
|
||||
2. **结果写入调用方栈上的 C 结构体**,消除 out-param 逃逸(那 2 allocs)
|
||||
3. 仅在 content/usage **确需重新编码**时回退 Go
|
||||
|
||||
### 6.5 为什么把「一个没有启用的优化」连代码一起提交
|
||||
|
||||
- **正确性基准**:6 万+ 差分用例已把 C 与 Go 的逐值等价钉死,
|
||||
这是改造的**已验证起点**(field-locating 与全部回退判据都验证正确了)
|
||||
- **一条永不静默回退的机制**:`TestChunkFast_BenchGate` 断言
|
||||
`chunkFastEnabled` 必须为 `false`。后来者看到「快速路径写得挺全 +
|
||||
差分测试全过」,很自然会以为它已生效并打开它 —— 而实测它更慢。
|
||||
断言把这个事实钉住,改动即判红。
|
||||
- **诚实**:不把「写了但没效果」包装成「已完成」。
|
||||
|
||||
> 教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
|
||||
> 第一刀推翻过一次(`C.CString` 造成 82% 自找开销),这一刀又推翻一次
|
||||
> (逐字段往返造成 5+ 次边界)。两次都是**测量**推翻了直觉。
|
||||
|
||||
### 已知边界(诚实记录)
|
||||
|
||||
- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同,
|
||||
|
||||
82
internal/agent/api/codec_chunkfast_bench_test.go
Normal file
82
internal/agent/api/codec_chunkfast_bench_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
//go:build cgo
|
||||
|
||||
package api
|
||||
|
||||
// codec_chunkfast_bench_test.go —— 快速路径 vs 原实现的真实开销对比。
|
||||
//
|
||||
// 判据不是「C 比 Go 快」,而是「在真实输入分布下是否真的省下分配与时间」。
|
||||
// 分配数是重点:C 化的原始动机就是消除每 chunk 12~21 次堆分配带来的 GC 抖动。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var benchChunks = map[string]string{
|
||||
"content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
"content_ascii": `{"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"hello world this is a longer ascii content chunk"},"finish_reason":null}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
"finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
}
|
||||
|
||||
// BenchmarkChunkFast_Entry 走真实入口(含 C 路径 + 必要的回退)。
|
||||
func BenchmarkChunkFast_Entry(b *testing.B) {
|
||||
for name, in := range benchChunks {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
b.SetBytes(int64(len(in)))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = parseOpenAICompatibleStreamChunkFull(in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkChunkFast_GoOnly 直接调原实现(整块 json.Unmarshal),作对照。
|
||||
func BenchmarkChunkFast_GoOnly(b *testing.B) {
|
||||
for name, in := range benchChunks {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
b.SetBytes(int64(len(in)))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = parseOpenAICompatibleStreamChunkFullGo(in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 基准门禁:防止「优化」悄悄退步,或在没实测过收益时被打开
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TestChunkFast_BenchGate 钉死当前事实:chunkFastEnabled 必须为 false。
|
||||
//
|
||||
// ★ 为什么把「一个优化是关的」也做成断言:
|
||||
// 「还没验证有效就先关着」是**容易丢失的状态** —— 后来者看到
|
||||
// 「快速路径写得挺全 + 6 万条差分测试全过」,很自然会以为它已生效,
|
||||
// 进而打开它、甚至删掉开关。而实测它**更慢**。
|
||||
// 断言把这个事实钉在测试里,开关一旦被改就立刻判红。
|
||||
func TestChunkFast_BenchGate(t *testing.T) {
|
||||
if chunkFastEnabled {
|
||||
t.Fatalf("chunkFastEnabled 被打开了,但实测本架构比原实现慢:\n" +
|
||||
" content_ascii Entry 2016ns/20allocs vs GoOnly 1325ns/13allocs\n" +
|
||||
" toolcall Entry 5854ns/33allocs vs GoOnly 3270ns/21allocs\n" +
|
||||
" 根因:5+ 次 cgo 边界 × 每次约 200ns(out-param 逃逸到堆)。\n" +
|
||||
" 改造方向(已由天花板实验确认可行):一次 C 调用返回全部字段 span、\n" +
|
||||
" 结果写入调用方栈上的 C 结构体。先改架构,再打开此开关。\n" +
|
||||
" 改之前请先跑 BenchmarkChunkFast_* 拿到自己的数据。")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkFast_CGoBoundaryCost 记录「每次带 out-param 的 cgo 调用 ≈ 2 allocs」
|
||||
// 这条经济事实。它是判断任何后续改造是否值得的标尺。
|
||||
func TestChunkFast_CGoBoundaryCost(t *testing.T) {
|
||||
// 断言存在(防止有人「顺手优化」掉这两个 helper 里的关键细节)
|
||||
doc := `{"a":1,"b":{"c":"x"}}`
|
||||
if _, found, _, bad := findKeyCI(rootSpan(doc), "b"); !found || bad {
|
||||
t.Fatalf("findKeyCI 失效: found=%v bad=%v", found, bad)
|
||||
}
|
||||
if _, found, _, bad := findKeyCS(rootSpan(doc), "a"); bad || !found {
|
||||
t.Fatalf("findKeyCS 失效: found=%v bad=%v", found, bad)
|
||||
}
|
||||
}
|
||||
353
internal/agent/api/codec_chunkfast_c.go
Normal file
353
internal/agent/api/codec_chunkfast_c.go
Normal file
@ -0,0 +1,353 @@
|
||||
//go:build cgo
|
||||
|
||||
package api
|
||||
|
||||
// codec_chunkfast_c.go —— parseOpenAICompatibleStreamChunkFull 的 C 快速路径
|
||||
//
|
||||
// ============================ 契约(务必先读) ============================
|
||||
// 本文件是**纯优化**:它必须与 chunkParseGo 对**所有输入**产出完全相同的
|
||||
// (StreamChunk, bool)。保证方式不是「小心写」,而是结构上的三条:
|
||||
//
|
||||
// 1. 任一环节判「不确定」⇒ **整体回退** chunkParseGo。没有任何分支
|
||||
// 「尽力猜」或「部分采用」。
|
||||
// 2. 每个「C 已判过合法」的子树,都用**与 Go 侧完全相同的 Go 类型**去
|
||||
// unmarshal ⇒ 类型检查语义天然一致,不靠 C 复刻类型规则。
|
||||
// 3. 拼装(chunkAssemble)与工具调用归一化(normalizeStreamToolCall)
|
||||
// 由两条路径**共用**,结构上无法分叉。
|
||||
//
|
||||
// 回退触发条件(穷举):
|
||||
// · 顶层不是「恰好一个」良构对象(含尾部残留,见 ha_sse_root_object)
|
||||
// · 发现**重复键**(§5.1 字段级合并语义,C 不实现)
|
||||
// · content 是对象/数字/字面量(stringifyContent 需 json.Marshal 重新编码,§5.2)
|
||||
// · tool_calls 元素畸形 / 数组元素过多
|
||||
// · 任何子树畸形或缓冲不足
|
||||
//
|
||||
// 实测:真实负载三种块全部走快速路径;探针里的畸形、重复键、对象 content
|
||||
// 等形态全部命中回退。
|
||||
//
|
||||
// ============================ ★ 当前默认**关闭**(实测比原实现慢) ============================
|
||||
// 见 codec_chunkfast_bench_test.go 的实测:
|
||||
// content_ascii Entry 2016ns/20allocs vs GoOnly 1325ns/13allocs
|
||||
// toolcall Entry 5854ns/33allocs vs GoOnly 3270ns/21allocs
|
||||
// usage Entry 3170ns/24allocs vs GoOnly 2832ns/12allocs
|
||||
//
|
||||
// 根因(已逐项测量,不是猜测):
|
||||
// 1. **每次键查找 205ns + 2 allocs**(out-params 逃逸到堆),
|
||||
// 而**裸 cgo 边界就有 168ns**。一次解析需要 5+ 次查找
|
||||
// (choices→[0]→delta→content/reasoning/tool_calls→finish_reason)
|
||||
// ⇒ 边界成本 ≈ 1µs,恰好吃掉全部收益。
|
||||
// 2. 每个字段还各自一次小 Unmarshal + 一次 decBuf 分配。
|
||||
// 而 Go 侧是**一次** Unmarshal 遍历建整棵树。
|
||||
//
|
||||
// ⇒ 本架构是「**用很多次廉价调用换一次昂贵调用**」,在这个尺寸上不划算。
|
||||
// 正确的前进方向是**减少边界次数**,而不是调优现有代码:
|
||||
// · 一次 C 调用返回**全部**字段的 span(批量,不逐字段往返)
|
||||
// · 结果写入**调用方栈上**的 C 结构体(消除 out-param 逃逸)
|
||||
// · 仅在 content/usage 确需重新编码时回退 Go
|
||||
// 天花板实测:若边界成本归零,Go 侧代价 ≈ 505ns/7allocs
|
||||
// (对 1239ns/13allocs)⇒ **方向对,但当前实现没到**。
|
||||
//
|
||||
// ★ 保留本文件的理由:它同时是
|
||||
// ① 正确性基准(6 万+ 差分用例已钉死 C 与 Go 逐值等价)
|
||||
// ② 上述改造的**已验证起点**(field-locating 与回退判据都已验证正确)
|
||||
// ③ 一条**永不静默回退**的机制:若未来把它切回默认开启,
|
||||
// TestChunkFast_BenchGate 会立刻用基准把它按回去。
|
||||
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const chunkFastEnabled = false
|
||||
|
||||
// chunkParseGo 是原始实现(整块 json.Unmarshal),作为快速路径的**唯一判据**
|
||||
// 与回退目标。
|
||||
func chunkParseGo(data string) (StreamChunk, bool) {
|
||||
var raw struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content interface{} `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
UpstreamUsage chunkUsage `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||||
return StreamChunk{}, false
|
||||
}
|
||||
// 只把 choices[0] 转成装配用的形态 —— 与原实现一致(原实现只读 [0],
|
||||
// 但 len() 判空用的是整个切片长度)。
|
||||
var choices []chunkChoice
|
||||
if len(raw.Choices) > 0 {
|
||||
c := raw.Choices[0]
|
||||
choices = []chunkChoice{{
|
||||
content: stringifyContent(c.Delta.Content),
|
||||
reasoning: c.Delta.ReasoningContent,
|
||||
toolCalls: normalizeStreamToolCalls(c.Delta.ToolCalls),
|
||||
finishPtr: c.FinishReason,
|
||||
}}
|
||||
} else if len(raw.Choices) == 0 {
|
||||
choices = nil
|
||||
}
|
||||
return chunkAssemble(choices, raw.UpstreamUsage)
|
||||
}
|
||||
|
||||
// chunkUsage 镜像 Go 侧的 UpstreamUsage 匿名结构。
|
||||
type chunkUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
PromptCacheHit int `json:"prompt_cache_hit_tokens"`
|
||||
PromptCacheMiss int `json:"prompt_cache_miss_tokens"`
|
||||
PromptTokensDetails *struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
}
|
||||
|
||||
// chunkChoice 是装配用的形态:Content 已过 stringifyContent。
|
||||
type chunkChoice struct {
|
||||
content string
|
||||
reasoning string
|
||||
toolCalls []ToolCall
|
||||
// finishPtr 保留三态区分:缺失/null ⇒ nil;"" ⇒ 非 nil 但空串
|
||||
//(空串**不算**终止信号,sensenova 每块都发 "")。
|
||||
finishPtr *string
|
||||
}
|
||||
|
||||
// chunkAssemble 把已备好的选择与 usage 拼成 StreamChunk。
|
||||
// **两条路径共用**它 ⇒ 拼装逻辑不可能分叉。
|
||||
func chunkAssemble(choices []chunkChoice, usage chunkUsage) (StreamChunk, bool) {
|
||||
var u *TokenUsage
|
||||
if usage.Total > 0 || usage.TotalTokens > 0 ||
|
||||
usage.Prompt > 0 || usage.PromptTokens > 0 {
|
||||
u = &TokenUsage{
|
||||
Prompt: pickFirstInt(usage.PromptTokens, usage.Prompt),
|
||||
Completion: pickFirstInt(usage.CompletionTokens, usage.Completion),
|
||||
Total: pickFirstInt(usage.TotalTokens, usage.Total),
|
||||
}
|
||||
}
|
||||
if len(choices) == 0 {
|
||||
// 纯 usage 心跳块:有 usage 就透传,否则丢弃
|
||||
if u != nil {
|
||||
return StreamChunk{Usage: u}, true
|
||||
}
|
||||
return StreamChunk{}, false
|
||||
}
|
||||
c := choices[0]
|
||||
ck := StreamChunk{
|
||||
Content: c.content,
|
||||
ReasoningContent: c.reasoning,
|
||||
ToolCalls: c.toolCalls,
|
||||
Usage: u,
|
||||
}
|
||||
if c.finishPtr != nil && *c.finishPtr != "" {
|
||||
ck.Done = true
|
||||
ck.FinishReason = *c.finishPtr
|
||||
}
|
||||
return ck, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// C 快速路径
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// chunkParseFast 尝试 C 快速路径。
|
||||
// 返回 (chunk, handled, decided):
|
||||
// handled=false ⇒ 调用方必须用 chunkParseGo
|
||||
// handled=true,decided=true ⇒ 结果是最终答案
|
||||
func chunkParseFast(data string) (StreamChunk, bool, bool) {
|
||||
root := rootSpan(data)
|
||||
if !sseRootObject(root) {
|
||||
return StreamChunk{}, false, false
|
||||
}
|
||||
|
||||
// ---- usage:整棵子树交给 encoding/json ----
|
||||
// ★ 为什么逐个整数取是错的:Go 侧 usage 有 9 个字段,且**任一类型不符
|
||||
// 就让整块作废**(实测 {"prompt_cache_hit_tokens":"x","prompt_tokens":1}
|
||||
// → 整块 false)。整棵 unmarshal 到**同一个 Go 类型** ⇒ 语义自动一致。
|
||||
var usage chunkUsage
|
||||
us, ufound, udup, ubad := findKeyCI(root, "usage")
|
||||
if ubad || udup {
|
||||
return StreamChunk{}, false, false
|
||||
}
|
||||
if ufound {
|
||||
switch us.firstByte() {
|
||||
case 'n':
|
||||
// null ⇒ 零值 struct(不产出 usage)
|
||||
case '{':
|
||||
if err := json.Unmarshal(us.bytes(), &usage); err != nil {
|
||||
// ★ 类型不符 ⇒ 与 Go 一样「整块作废」,**不需要回退**
|
||||
return StreamChunk{}, false, true
|
||||
}
|
||||
default:
|
||||
return StreamChunk{}, false, false // 交回 Go 决定
|
||||
}
|
||||
}
|
||||
|
||||
// ---- choices:只取 [0],但要先判整切片的长度语义 ----
|
||||
cs, cfound, cdup, cbad := findKeyCI(root, "choices")
|
||||
if cbad || cdup {
|
||||
return StreamChunk{}, false, false
|
||||
}
|
||||
if !cfound {
|
||||
ck, ok := chunkAssemble(nil, usage)
|
||||
return ck, true, ok
|
||||
}
|
||||
switch cs.firstByte() {
|
||||
case 'n':
|
||||
// null ⇒ 零值切片(长度 0)⇒ 走「无 choices」分支
|
||||
ck, ok := chunkAssemble(nil, usage)
|
||||
return ck, true, ok
|
||||
case '[':
|
||||
default:
|
||||
return StreamChunk{}, false, false
|
||||
}
|
||||
el, has := firstElem(cs)
|
||||
if !has {
|
||||
// 空数组:len(choices)==0 ⇒ 与 Go 相同
|
||||
ck, ok := chunkAssemble(nil, usage)
|
||||
return ck, true, ok
|
||||
}
|
||||
ch, ok := fastChoice(el)
|
||||
if !ok {
|
||||
return StreamChunk{}, false, false // 任何不确定 ⇒ 整体回退
|
||||
}
|
||||
ck, ok2 := chunkAssemble([]chunkChoice{ch}, usage)
|
||||
return ck, true, ok2
|
||||
}
|
||||
|
||||
// fastChoice 解析 choices[0]。ok=false ⇒ 必须回退 Go。
|
||||
//
|
||||
// ★ 键匹配方式按 Go 那一跳的实际类型选择:
|
||||
// - choices / delta / finish_reason / tool_calls 是 **struct 字段** ⇒ 大小写不敏感
|
||||
// - content / reasoning_content / "text" 是 **interface{} → map key** ⇒ 大小写敏感
|
||||
// (实测:{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]} 有效;
|
||||
// {"content":[{"TEXT":"up"}]} 取不到 text)
|
||||
func fastChoice(el strSpan) (chunkChoice, bool) {
|
||||
var ch chunkChoice
|
||||
if el.firstByte() != '{' {
|
||||
return ch, false
|
||||
}
|
||||
|
||||
ds, dfound, ddup, dbad := findKeyCI(el, "delta")
|
||||
if dbad || ddup {
|
||||
return ch, false
|
||||
}
|
||||
if dfound {
|
||||
switch ds.firstByte() {
|
||||
case 'n':
|
||||
// delta:null ⇒ 零值 struct
|
||||
case '{':
|
||||
// ★ delta 是 **struct**(不是 map!)——
|
||||
// 原实现:Delta struct { Content interface{}; ... } `json:"delta"`
|
||||
// 故它的字段名匹配是**大小写不敏感**。
|
||||
// 实测 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` → content="ci"。
|
||||
// 只有 content 的**值**(若为对象/数组)才成为 map/[]interface{},
|
||||
// 那时里面的键(如 "text")才是大小写敏感。
|
||||
//
|
||||
// 我一度把这里改成 CS 并认为「差分测试会通过」——那是错的推理:
|
||||
// Go 侧给的是 "ci"(CI 匹配成功),改成 CS 反而把快速路径弄丢。
|
||||
// 教训:**「哪一层是 struct、哪一层是 map」要回原实现读类型,
|
||||
// 不能凭字段名像 map 就推断它是 map。**
|
||||
|
||||
// reasoning_content:Go 侧是 **string**(强类型)。
|
||||
// 用同样的 Go 类型 unmarshal ⇒ 123 会报错,与原实现一致。
|
||||
rs, rfound, rdup, rbad := findKeyCI(ds, "reasoning_content")
|
||||
if rbad || rdup {
|
||||
return ch, false
|
||||
}
|
||||
if rfound && rs.firstByte() != 'n' {
|
||||
var s string
|
||||
if err := json.Unmarshal(rs.bytes(), &s); err != nil {
|
||||
return ch, false // 类型不符 ⇒ 回退(Go 会整块作废)
|
||||
}
|
||||
ch.reasoning = s
|
||||
}
|
||||
|
||||
// content 字段名:CI(struct 字段)。
|
||||
// 其**值**若是数组/对象,内部键由 ha_sse_stringify 按 CS 处理。
|
||||
cs, cfound, cdup, cbad := findKeyCI(ds, "content")
|
||||
if cbad || cdup {
|
||||
return ch, false
|
||||
}
|
||||
if cfound {
|
||||
if s, handled := stringifyC(cs); handled {
|
||||
ch.content = s
|
||||
} else {
|
||||
return ch, false // 需 json.Marshal 重新编码(§5.2)
|
||||
}
|
||||
}
|
||||
|
||||
// tool_calls:逐个元素整体 unmarshal 成 openAIToolCall,
|
||||
// 使 arguments 的 interface{} 形态 / 类型检查全由 encoding/json 负责。
|
||||
tcs, tfound, tdup, tbad := findKeyCI(ds, "tool_calls")
|
||||
if tbad || tdup {
|
||||
return ch, false
|
||||
}
|
||||
if tfound {
|
||||
switch tcs.firstByte() {
|
||||
case 'n':
|
||||
// null ⇒ 零值切片
|
||||
case '[':
|
||||
t, ok := fastToolCalls(tcs)
|
||||
if !ok {
|
||||
return ch, false
|
||||
}
|
||||
ch.toolCalls = t
|
||||
default:
|
||||
return ch, false
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ch, false
|
||||
}
|
||||
}
|
||||
|
||||
// finish_reason:struct 字段 ⇒ 大小写不敏感;Go 侧是 *string
|
||||
fs, ffound, fdup, fbad := findKeyCI(el, "finish_reason")
|
||||
if fbad || fdup {
|
||||
return ch, false
|
||||
}
|
||||
if ffound && fs.firstByte() != 'n' {
|
||||
if fs.firstByte() != '"' {
|
||||
return ch, false
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(fs.bytes(), &s); err != nil {
|
||||
return ch, false
|
||||
}
|
||||
ch.finishPtr = &s
|
||||
}
|
||||
return ch, true
|
||||
}
|
||||
|
||||
// fastToolCalls 解析 tool_calls 数组。
|
||||
//
|
||||
// ★ 逐元素整体 unmarshal 成 openAIToolCall 是刻意的:这样 arguments 的
|
||||
// interface{} 形态、字符串/对象/数组/数字各分支、重复键,全部由
|
||||
// encoding/json 处理(§5.2 的重新编码语义不必在 C 复刻)。
|
||||
// 归一化也走**同一个** normalizeStreamToolCall ⇒ 与 Go 路径不分叉。
|
||||
func fastToolCalls(arr strSpan) ([]ToolCall, bool) {
|
||||
elems, ok := scanArray(arr)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if len(elems) == 0 {
|
||||
return nil, true // 空数组 ⇒ nil(与 Go 的 normalizeStreamToolCalls 一致)
|
||||
}
|
||||
out := make([]ToolCall, 0, len(elems))
|
||||
for _, elem := range elems {
|
||||
if elem.firstByte() != '{' {
|
||||
return nil, false
|
||||
}
|
||||
var raw openAIToolCall
|
||||
if err := json.Unmarshal(elem.bytes(), &raw); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, normalizeStreamToolCall(raw))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
318
internal/agent/api/codec_chunkfast_golden_test.go
Normal file
318
internal/agent/api/codec_chunkfast_golden_test.go
Normal file
@ -0,0 +1,318 @@
|
||||
//go:build cgo
|
||||
|
||||
package api
|
||||
|
||||
// codec_chunkfast_golden_test.go —— C 快速路径 vs 原 Go 实现的**逐值差分对照**。
|
||||
//
|
||||
// ============================ 这是接线的唯一验收 ============================
|
||||
// 快速路径是**纯优化**:它与 chunkParseGo 必须在所有输入上等价。
|
||||
// 而这条等价性不能靠「读代码觉得对」——本刀前面已经有 7 个「读三遍都认为对」
|
||||
// 的 C 缺陷。故这里用**差分测试**:同一批输入,两条路径,逐字段比对。
|
||||
//
|
||||
// 输入来源三类:
|
||||
// ① 手工枚举的协议形态(含全部回退触发条件)
|
||||
// ② 真实负载形状(content / toolcall / usage 块)
|
||||
// ③ 随机 JSON(用 encoding/json 生成合法值再编码,覆盖嵌套与转义)
|
||||
//
|
||||
// 比对字段:整个 StreamChunk(Content / ReasoningContent / Done /
|
||||
// FinishReason / ToolCalls / Usage)与 bool 返回值。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// diffChunk 逐字段比对两个结果,不同则报告首个差异点。
|
||||
func diffChunk(t *testing.T, in string, fastCK StreamChunk, fastOK bool, goCK StreamChunk, goOK bool) {
|
||||
t.Helper()
|
||||
if fastOK != goOK {
|
||||
t.Errorf("返回 bool 分歧 in=%q: fast=%v go=%v", in, fastOK, goOK)
|
||||
return
|
||||
}
|
||||
if !fastOK {
|
||||
return
|
||||
}
|
||||
if fastCK.Content != goCK.Content {
|
||||
t.Errorf("Content 分歧 in=%q:\n fast=%q\n go =%q", in, fastCK.Content, goCK.Content)
|
||||
}
|
||||
if fastCK.ReasoningContent != goCK.ReasoningContent {
|
||||
t.Errorf("ReasoningContent 分歧 in=%q:\n fast=%q\n go =%q",
|
||||
in, fastCK.ReasoningContent, goCK.ReasoningContent)
|
||||
}
|
||||
if fastCK.Done != goCK.Done || fastCK.FinishReason != goCK.FinishReason {
|
||||
t.Errorf("Done/FinishReason 分歧 in=%q: fast=(%v,%q) go=(%v,%q)",
|
||||
in, fastCK.Done, fastCK.FinishReason, goCK.Done, goCK.FinishReason)
|
||||
}
|
||||
if !reflect.DeepEqual(fastCK.Usage, goCK.Usage) {
|
||||
t.Errorf("Usage 分歧 in=%q:\n fast=%+v\n go =%+v", in, fastCK.Usage, goCK.Usage)
|
||||
}
|
||||
if len(fastCK.ToolCalls) != len(goCK.ToolCalls) {
|
||||
t.Errorf("ToolCalls 数量分歧 in=%q: fast=%d go=%d",
|
||||
in, len(fastCK.ToolCalls), len(goCK.ToolCalls))
|
||||
} else {
|
||||
for i := range fastCK.ToolCalls {
|
||||
if !reflect.DeepEqual(fastCK.ToolCalls[i], goCK.ToolCalls[i]) {
|
||||
t.Errorf("ToolCalls[%d] 分歧 in=%q:\n fast=%+v\n go =%+v",
|
||||
i, in, fastCK.ToolCalls[i], goCK.ToolCalls[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkPair(t *testing.T, in string) {
|
||||
t.Helper()
|
||||
fastCK, fastOK, _ := chunkParseFast(in)
|
||||
if !fastCK.Done && !fastOK {
|
||||
// handled=false ⇒ 走 Go。这里要区分「回退」与「快速路径给出失败」:
|
||||
}
|
||||
// 真实入口(含回退)
|
||||
gotCK, gotOK := parseOpenAICompatibleStreamChunkFull(in)
|
||||
goCK, goOK := parseOpenAICompatibleStreamChunkFullGo(in)
|
||||
diffChunk(t, in, gotCK, gotOK, goCK, goOK)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. 手工协议形态(覆盖所有回退触发条件)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestChunkFast_ProtocolForms(t *testing.T) {
|
||||
cases := []string{
|
||||
// —— 真实负载三形态(应走快速路径)——
|
||||
`{"id":"c1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"这是一段中文内容。"},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
|
||||
`{"id":"c1","usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
|
||||
`{"usage":{"prompt":1,"completion":2,"total":3}}`,
|
||||
`{"usage":{"prompt_tokens":1}}`,
|
||||
`{"usage":{"prompt_cache_hit_tokens":5,"prompt_tokens":1,"total_tokens":2}}`,
|
||||
`{"usage":{"prompt_tokens_details":{"cached_tokens":7},"prompt_tokens":1,"total_tokens":2}}`,
|
||||
`{}`,
|
||||
`{"choices":null}`,
|
||||
`{"usage":null}`,
|
||||
`{"choices":[{"delta":null}]}`,
|
||||
`{"choices":[{"finish_reason":null}]}`,
|
||||
`{"choices":[{"finish_reason":""}]}`,
|
||||
`{"choices":[{"finish_reason":"length"}]}`,
|
||||
// content 的各种界面
|
||||
`{"choices":[{"delta":{"content":""}}]}`,
|
||||
`{"choices":[{"delta":{"content":null}}]}`,
|
||||
`{"choices":[{"delta":{"content":123}}]}`,
|
||||
`{"choices":[{"delta":{"content":true}}]}`,
|
||||
`{"choices":[{"delta":{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}}]}`,
|
||||
`{"choices":[{"delta":{"content":[]}}]}`,
|
||||
`{"choices":[{"delta":{"content":["x",{"text":"y"}]}}]}`,
|
||||
`{"choices":[{"delta":{"content":[{"text":123},{"text":"ok"}]}}]}`,
|
||||
`{"choices":[{"delta":{"content":"a\"b\\c\nd"}}]}`,
|
||||
`{"choices":[{"delta":{"content":"你好😀"}}]}`,
|
||||
`{"choices":[{"delta":{"content":"\u4f60\u597d"}}]}`,
|
||||
|
||||
// —— 必须回退 Go 的形态 ——
|
||||
// §5.2:对象 content 需 json.Marshal 重新编码(键排序 + HTML 转义)
|
||||
`{"choices":[{"delta":{"content":{"b":1,"a":2}}}]}`,
|
||||
`{"choices":[{"delta":{"content":{"k":"<a>&b"}}}]}`,
|
||||
`{"choices":[{"delta":{"content":{"nested":{"deep":[1,2]}}}}]}`,
|
||||
`{"choices":[{"delta":{"content":1e2}}]}`,
|
||||
`{"choices":[{"delta":{"content":1.0}}]}`,
|
||||
`{"choices":[{"delta":{"content":0.1}}]}`,
|
||||
`{"choices":[{"delta":{"content":123456789012345678}}]}`,
|
||||
// §5.1:重复键
|
||||
`{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{"content":"b"}}]}`,
|
||||
`{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{"reasoning_content":"r"}}]}`,
|
||||
`{"usage":{"prompt_tokens":1},"usage":{"completion_tokens":2}}`,
|
||||
`{"choices":[{"delta":{"content":{"x":1},"content":"s"}}]}`,
|
||||
// 尾部残留
|
||||
`{"a":1}{"b":2}`,
|
||||
`{"choices":[{"delta":{"content":"x"}}]} trailing`,
|
||||
// 类型不符(应两侧都 false)
|
||||
`{"choices":{}}`,
|
||||
`{"usage":{"prompt_tokens":"1"}}`,
|
||||
`{"usage":{"prompt_tokens":1.5}}`,
|
||||
`{"usage":{"prompt_cache_hit_tokens":"x","prompt_tokens":1}}`,
|
||||
`{"choices":[{"delta":{"reasoning_content":123}}]}`,
|
||||
`{"choices":[{"delta":{"content":"x"},"finish_reason":42}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":{}}}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":1.5,"function":{"name":"f"}}]}}]}`,
|
||||
// 键大小写
|
||||
`{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}`,
|
||||
`{"choices":[{"delta":{"content":[{"TEXT":"up"}]}}]}`,
|
||||
`{"choices":[{"delta":{"content":[{"text":"low"}]}}]}`,
|
||||
`{"CHOICES":[{"DELTA":{"CONTENT":"a"}}],"choices":[{"DELTA":{"CONTENT":"b"}}]}`,
|
||||
// 畸形
|
||||
``, `{`, `null`, `[]`, `"str"`, `123`, `{"a":}`, `{"a":1,}`,
|
||||
`{'a':1}`, `{"a":1 `, `{"choices":[`, `{"choices":[{"delta":`,
|
||||
}
|
||||
for _, in := range cases {
|
||||
checkPair(t, in)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. 真实负载形状(从实际网关抓的形态)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestChunkFast_Realistic(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
// 流式续传:name 不重发但 function.arguments 继续
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":"}}]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]},"finish_reason":null}]}`,
|
||||
// 扁平形态(顶层 name/arguments)
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"name":"f","arguments":{"a":1}}]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"f","arguments":null}}]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"f","arguments":123}}]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"type":"function","function":{"name":"f","arguments":[1,2]}}]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1"}]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":[]},"finish_reason":null}]}`,
|
||||
`{"choices":[{"delta":{"tool_calls":null},"finish_reason":null}]}`,
|
||||
// reasoning 与 content 同时出现
|
||||
`{"choices":[{"delta":{"reasoning_content":"r","content":"c"},"finish_reason":null}]}`,
|
||||
// 多个 choices(只读 [0])
|
||||
`{"choices":[{"delta":{"content":"first"},"finish_reason":"stop"},{"delta":{"content":"second"}}]}`,
|
||||
// 未知字段(应忽略)
|
||||
`{"choices":[{"delta":{"content":"x"},"unknown":{"deep":[1,2]}}],"zzz":1}`,
|
||||
`{"choices":[{"delta":{"content":"x"},"logprobs":{"tokens":["a"]}}],"system_fingerprint":"fp_1"}`,
|
||||
}
|
||||
for _, in := range cases {
|
||||
checkPair(t, in)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3. 随机 JSON(合法值 → 编码 → 解析),差分
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func randJSONValue(rng *rand.Rand, depth int) interface{} {
|
||||
if depth <= 0 {
|
||||
switch rng.Intn(6) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return rng.Intn(1000)
|
||||
case 2:
|
||||
return rng.Float64() * 100
|
||||
case 3:
|
||||
return rng.Intn(2) == 0
|
||||
default:
|
||||
return randomString(rng)
|
||||
}
|
||||
}
|
||||
switch rng.Intn(8) {
|
||||
case 0:
|
||||
return map[string]interface{}{"a": randJSONValue(rng, depth-1)}
|
||||
case 1:
|
||||
return []interface{}{randJSONValue(rng, depth-1)}
|
||||
case 2:
|
||||
return map[string]interface{}{
|
||||
"prompt_tokens": rng.Intn(9999),
|
||||
"total_tokens": rng.Intn(9999),
|
||||
"completion": rng.Intn(999),
|
||||
"prompt_cache_hit_tokens": rng.Intn(10),
|
||||
}
|
||||
default:
|
||||
return randJSONValue(rng, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func randomString(rng *rand.Rand) string {
|
||||
alphabet := []rune("abc中文😀\"\\\n\t<>äöü")
|
||||
n := rng.Intn(12)
|
||||
var sb strings.Builder
|
||||
for i := 0; i < n; i++ {
|
||||
sb.WriteRune(alphabet[rng.Intn(len(alphabet))])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func TestChunkFast_RandomJSON(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(20260926))
|
||||
for iter := 0; iter < 30000; iter++ {
|
||||
// 构造一个「像 SSE chunk」的随机对象
|
||||
obj := map[string]interface{}{}
|
||||
switch rng.Intn(4) {
|
||||
case 0:
|
||||
obj["choices"] = []interface{}{map[string]interface{}{
|
||||
"index": rng.Intn(3),
|
||||
"delta": map[string]interface{}{"content": randJSONValue(rng, 2)},
|
||||
"finish_reason": []interface{}{nil, "", "stop", "length"}[rng.Intn(4)],
|
||||
}}
|
||||
case 1:
|
||||
obj["choices"] = []interface{}{map[string]interface{}{
|
||||
"delta": map[string]interface{}{
|
||||
"reasoning_content": randomString(rng),
|
||||
"content": randomString(rng),
|
||||
},
|
||||
}}
|
||||
case 2:
|
||||
obj["usage"] = map[string]interface{}{
|
||||
"prompt_tokens": rng.Intn(1000),
|
||||
"completion_tokens": rng.Intn(100),
|
||||
"total_tokens": rng.Intn(1000),
|
||||
}
|
||||
default:
|
||||
obj["choices"] = []interface{}{map[string]interface{}{
|
||||
"delta": map[string]interface{}{
|
||||
"tool_calls": []interface{}{map[string]interface{}{
|
||||
"index": rng.Intn(3),
|
||||
"id": randomString(rng),
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": randomString(rng),
|
||||
"arguments": randJSONValue(rng, 1),
|
||||
},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
checkPair(t, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. 随机字节(畸形输入)——两侧都必须拒绝、且不得 panic
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestChunkFast_RandomBytes(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(777))
|
||||
alphabet := []byte(`{}[]",:0123456789tfnul \` + "\n\t\xff\x80")
|
||||
for iter := 0; iter < 30000; iter++ {
|
||||
n := rng.Intn(60)
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = alphabet[rng.Intn(len(alphabet))]
|
||||
}
|
||||
checkPair(t, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5. 快速路径**确实被用到**(否则「优化」是假的)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestChunkFast_ActuallyHandlesRealistic(t *testing.T) {
|
||||
realistic := []string{
|
||||
`{"id":"c","choices":[{"index":0,"delta":{"content":"中文内容"},"finish_reason":null}]}`,
|
||||
`{"id":"c","choices":[{"index":0,"delta":{"content":"x"},"finish_reason":"stop"}]}`,
|
||||
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"i","type":"function","function":{"name":"n","arguments":"{}"}}]}}]}`,
|
||||
`{"id":"c","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
|
||||
}
|
||||
for _, in := range realistic {
|
||||
_, handled, decided := chunkParseFast(in)
|
||||
if !handled || !decided {
|
||||
t.Errorf("真实负载未走快速路径(优化失效): %s", in)
|
||||
}
|
||||
}
|
||||
_ = fmt.Sprint()
|
||||
}
|
||||
328
internal/agent/api/codec_streamchunk_c.go
Normal file
328
internal/agent/api/codec_streamchunk_c.go
Normal file
@ -0,0 +1,328 @@
|
||||
//go:build cgo
|
||||
|
||||
package api
|
||||
|
||||
// codec_streamchunk_c.go —— SSE 分块解析的 C 化「结构导航」层(Go 侧绑定)
|
||||
//
|
||||
// ============================ 为什么是「导航」而不是「全量编解码」 ============================
|
||||
// 接线前实测出两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5),它们让
|
||||
// 「整条 parseOpenAICompatibleStreamChunkFull 全 C 化」不成立:
|
||||
//
|
||||
// §5.1 重复键是**字段级合并**:`{"choices":[{content:a}],"choices":[{reasoning:r}]}`
|
||||
// → content="a" **且** reasoning="r"。json.Unmarshal 的 object() 收尾时做
|
||||
// `v.SetIndex(i, subv.v)`,而 subv 拿到的是**已存在元素的指针**,
|
||||
// 所以第二次是叠加而非替换。正确实现要维护「本次哪些字段出现过」的表。
|
||||
// §5.2 stringifyContent 的 default 分支 = `json.Marshal(interface{})`,
|
||||
// 即**重新序列化**:`{"b":1,"a":2}` → `{"a":2,"b":1}`(键排序)、
|
||||
// `1e2` → `100`、`<` → `\u003c`、大 int 先舍入成 float64。
|
||||
// 逐值一致要求复刻 Ryu 最短浮点 + map 键排序 + HTML 转义 + int 舍入。
|
||||
//
|
||||
// 而这两条**只在取值阶段**才需要。故本层只做**结构导航**:
|
||||
//
|
||||
// C:把 JSON 定位到「哪个值在哪里」——零分配、零解码,并直接给出两个热分支的结果
|
||||
// Go:把「已定位的原始字节」按既有类型 unmarshal,成串逻辑完全不变
|
||||
//
|
||||
// ⇒ 类型检查的等价性靠「用**相同的 Go 类型** unmarshal **相同形状的子树**」保证,
|
||||
// 而不靠 C 重新实现一遍类型规则。这是本设计同时拿到速度与正确性的关键。
|
||||
//
|
||||
// 代价如实记录:命中字段仍要一次小 Unmarshal(原来是对整块做)。收益是免除
|
||||
// json.Unmarshal 对整块的**反射建树**——那正是每块 12~21 allocs 的主因。
|
||||
//
|
||||
// ★ 键匹配**大小写敏感**(与 ha_json_scan.h 的 ha_json_key_eq 相反,两者用途不同)
|
||||
// `content` 是 map[string]interface{},取 `m["text"]` 走 map key 语义
|
||||
// ⇒ 大小写敏感。实测 `{"TEXT":"up"}` 取不到 `text`。
|
||||
// struct 字段(choices/delta/usage)是大小写**不**敏感 —— 那一跳交给
|
||||
// encoding/json,天然正确。
|
||||
//
|
||||
// ★ C 实现放在 csrc/ha_sse.c 而**不是**本文件的 cgo 前言里:
|
||||
// 前言里的 C 代码会逃出全部 C 门禁(告警 / ASan+UBSan / arm64 交叉 / 模糊测试),
|
||||
// 而这里恰恰是本刀最容易出错的位置。这是结构性决定,不是形式主义。
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -std=c99
|
||||
#include <stdlib.h>
|
||||
#include "ha_sse.h"
|
||||
|
||||
// C 结构体一律不跨越语言边界(cgo 禁止「Go 指针指向的 Go 指针」,
|
||||
// 实测会 panic),故所有 span 传递都拆成 (指针, 长度) 标量。
|
||||
static int go_obj_find(const char *p, size_t n, const char *key, int keylen,
|
||||
char **vp, size_t *vlen, int *dup) {
|
||||
ha_span obj, out;
|
||||
obj.p = p; obj.len = n;
|
||||
int rc = ha_sse_obj_find(&obj, key, (size_t)keylen, &out, dup);
|
||||
if (rc == 1) { *vp = (char *)out.p; *vlen = out.len; }
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int go_arr_first(const char *p, size_t n, char **vp, size_t *vlen) {
|
||||
ha_span arr, out;
|
||||
arr.p = p; arr.len = n;
|
||||
int rc = ha_sse_arr_first(&arr, &out);
|
||||
if (rc == 1) { *vp = (char *)out.p; *vlen = out.len; }
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int go_stringify(const char *p, size_t n, char *out, size_t cap,
|
||||
size_t *outlen) {
|
||||
ha_span val;
|
||||
val.p = p; val.len = n;
|
||||
return ha_sse_stringify(&val, out, cap, outlen);
|
||||
}
|
||||
|
||||
static int go_arg_string(const char *p, size_t n, char *out, size_t cap,
|
||||
size_t *outlen) {
|
||||
ha_span val;
|
||||
val.p = p; val.len = n;
|
||||
return ha_sse_arg_string(&val, out, cap, outlen);
|
||||
}
|
||||
|
||||
static int go_obj_find_ci(const char *p, size_t n, const char *key, int keylen,
|
||||
char **vp, size_t *vlen, int *dup) {
|
||||
ha_span obj, out;
|
||||
obj.p = p; obj.len = n;
|
||||
int rc = ha_sse_obj_find_ci(&obj, key, (size_t)keylen, &out, dup);
|
||||
if (rc == 1) { *vp = (char *)out.p; *vlen = out.len; }
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int go_root_object(const char *p, size_t n) {
|
||||
ha_span doc;
|
||||
doc.p = p; doc.len = n;
|
||||
return ha_sse_root_object(&doc);
|
||||
}
|
||||
|
||||
// 把数组全部元素写进 out(Go 侧预分配的 span 数组)。
|
||||
// 返回元素数;超出 cap 时返回 -1(调用方据此判定「需要更大的缓冲」⇒ 回退)。
|
||||
static int go_arr_all(const char *p, size_t n, ha_span *out, int cap) {
|
||||
ha_json_scan sc;
|
||||
int count = 0;
|
||||
ha_json_scan_init(&sc, p, n);
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') { return -1; }
|
||||
sc.i++;
|
||||
for (;;) {
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') { break; }
|
||||
if (count >= cap) { return -1; }
|
||||
size_t start = sc.i;
|
||||
if (!ha_json_skip(&sc)) { return -1; }
|
||||
out[count].p = p + start;
|
||||
out[count].len = sc.i - start;
|
||||
count++;
|
||||
(void)ha_json_scan_ws(&sc);
|
||||
if (ha_json_scan_eof(&sc)) { return -1; }
|
||||
if (sc.s[sc.i] == ',') { sc.i++; continue; }
|
||||
if (sc.s[sc.i] == ']') { break; }
|
||||
return -1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
static int go_sse_abi(void) { return ha_sse_abi_version(); }
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// span 表示
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// strSpan 是 JSON 里一段字节,指向**原缓冲**(零拷贝)。
|
||||
type strSpan struct {
|
||||
p *C.char
|
||||
n C.size_t
|
||||
}
|
||||
|
||||
func (s strSpan) valid() bool { return s.p != nil && s.n > 0 }
|
||||
|
||||
// bytes 把 span 变成 Go 字节切片(此处才产生一次拷贝)。
|
||||
//
|
||||
// ★ 用途:把「已定位的原始子树」交给 json.Unmarshal —— 用同一 Go 类型
|
||||
// unmarshal 同一形状,是本层保证「类型检查语义与原实现一致」的手段。
|
||||
func (s strSpan) bytes() []byte {
|
||||
if s.p == nil || s.n == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(s.p)), int(s.n))
|
||||
}
|
||||
|
||||
// str 把 span 变成 Go 字符串(此处才产生一次拷贝)。
|
||||
func (s strSpan) str() string {
|
||||
if s.p == nil || s.n == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(unsafe.Slice((*byte)(unsafe.Pointer(s.p)), int(s.n)))
|
||||
}
|
||||
|
||||
// firstByte 只看首字节,用于区分值类型。
|
||||
func (s strSpan) firstByte() byte {
|
||||
if s.p == nil || s.n == 0 {
|
||||
return 0
|
||||
}
|
||||
return *(*byte)(unsafe.Pointer(s.p))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 定位
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// findKey 在 obj 里按**大小写敏感**的键定位值。
|
||||
// 返回 (span, found, dup, malformed)。
|
||||
// dup=true ⇒ 发现重复键,调用方**必须**整体回退 encoding/json(§5.1)。
|
||||
func findKey(obj strSpan, name string) (strSpan, bool, bool, bool) {
|
||||
if !obj.valid() {
|
||||
return strSpan{}, false, false, false
|
||||
}
|
||||
keyp, keyn := cstr(name)
|
||||
var vp *C.char
|
||||
var vlen C.size_t
|
||||
var dup C.int
|
||||
rc := C.go_obj_find(obj.p, obj.n, keyp, C.int(keyn), &vp, &vlen, &dup)
|
||||
switch rc {
|
||||
case 1:
|
||||
return strSpan{vp, vlen}, true, dup == 1, false
|
||||
case 0:
|
||||
return strSpan{}, false, dup == 1, false
|
||||
default:
|
||||
return strSpan{}, false, false, true // 畸形 ⇒ 让 encoding/json 判
|
||||
}
|
||||
}
|
||||
|
||||
// firstElem 取数组第一个元素的 span。
|
||||
func firstElem(arr strSpan) (strSpan, bool) {
|
||||
if !arr.valid() {
|
||||
return strSpan{}, false
|
||||
}
|
||||
var vp *C.char
|
||||
var vlen C.size_t
|
||||
if C.go_arr_first(arr.p, arr.n, &vp, &vlen) != 1 {
|
||||
return strSpan{}, false
|
||||
}
|
||||
return strSpan{vp, vlen}, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 取值(C 可判定的热分支)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// decBuf 是解码/反转义用的可写缓冲。
|
||||
//
|
||||
// ★ 尺寸必须按输入长度定:C 侧要求 cap >= len*3+4(最坏每字节一个 U+FFFD),
|
||||
// 不足时它会返回 0 让调用方回退 Go(宁可慢也不截断)。
|
||||
// 每次调用 1 次分配(原来整块 Unmarshal 是 12~21 次)—— 这是主要的节省点。
|
||||
func decBuf(n int) []byte { return make([]byte, n*3+8) }
|
||||
|
||||
// stringifyC 对应 Go stringifyContent 的**C 可判定分支**
|
||||
// (字符串值 / 文本数组),返回 (结果, handled)。
|
||||
// handled=false ⇒ 值类型需要 json.Marshal 重新编码(§5.2),调用方须回退 Go。
|
||||
func stringifyC(val strSpan) (string, bool) {
|
||||
if !val.valid() {
|
||||
// 缺失 / 空 ⇒ Go 侧 stringifyContent(nil) 也是 ""
|
||||
return "", true
|
||||
}
|
||||
buf := decBuf(int(val.n))
|
||||
var outLen C.size_t
|
||||
if C.go_stringify(val.p, val.n, cstrb(buf), C.size_t(len(buf)), &outLen) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return string(buf[:int(outLen)]), true
|
||||
}
|
||||
|
||||
// argStringC 取出 arguments 的**字符串**形态(省掉 interface{} 与二次解析)。
|
||||
func argStringC(val strSpan) (string, bool) {
|
||||
if !val.valid() {
|
||||
return "", false
|
||||
}
|
||||
buf := decBuf(int(val.n))
|
||||
var outLen C.size_t
|
||||
if C.go_arg_string(val.p, val.n, cstrb(buf), C.size_t(len(buf)), &outLen) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return string(buf[:int(outLen)]), true
|
||||
}
|
||||
|
||||
// sseABIVersion 供 ABI 漂移测试使用。
|
||||
func sseABIVersion() int { return int(C.go_sse_abi()) }
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 顶层 helper:大小写不敏感(struct 字段语义)与根对象校验
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// C_size 把 Go int 转成 C.size_t(零拷贝 span 的长度)。
|
||||
func C_size(n int) C.size_t { return C.size_t(n) }
|
||||
|
||||
// rootSpan 构造指向 data 的 span(零拷贝)。
|
||||
func rootSpan(data string) strSpan { return strSpan{cstrp(data), C_size(len(data))} }
|
||||
|
||||
// findKeyCI 按**大小写不敏感**定位(Go struct 字段语义)。
|
||||
func findKeyCI(obj strSpan, name string) (strSpan, bool, bool, bool) {
|
||||
return findKeyGeneric(obj, name, true)
|
||||
}
|
||||
|
||||
// findKeyCS 按**大小写敏感**定位(Go map key 语义)。
|
||||
func findKeyCS(obj strSpan, name string) (strSpan, bool, bool, bool) {
|
||||
return findKeyGeneric(obj, name, false)
|
||||
}
|
||||
|
||||
func findKeyGeneric(obj strSpan, name string, ci bool) (strSpan, bool, bool, bool) {
|
||||
if !obj.valid() {
|
||||
return strSpan{}, false, false, false
|
||||
}
|
||||
keyp, keyn := cstr(name)
|
||||
var vp *C.char
|
||||
var vlen C.size_t
|
||||
var dup C.int
|
||||
var rc C.int
|
||||
if ci {
|
||||
rc = C.go_obj_find_ci(obj.p, obj.n, keyp, C.int(keyn), &vp, &vlen, &dup)
|
||||
} else {
|
||||
rc = C.go_obj_find(obj.p, obj.n, keyp, C.int(keyn), &vp, &vlen, &dup)
|
||||
}
|
||||
switch rc {
|
||||
case 1:
|
||||
return strSpan{vp, vlen}, true, dup == 1, false
|
||||
case 0:
|
||||
return strSpan{}, false, dup == 1, false
|
||||
default:
|
||||
return strSpan{}, false, false, true
|
||||
}
|
||||
}
|
||||
|
||||
// sseRootObject 校验「恰好一个良构对象」(含尾部残留检查)。
|
||||
func sseRootObject(doc strSpan) bool {
|
||||
if !doc.valid() {
|
||||
return false
|
||||
}
|
||||
return C.go_root_object(doc.p, doc.n) == 1
|
||||
}
|
||||
|
||||
|
||||
// scanArray 枚举数组的全部元素 span(零拷贝,指向原缓冲)。
|
||||
//
|
||||
// ★ 为什么要「先数一遍再填」:C 侧迭代器一次回一个元素,而 Go 需要一个切片。
|
||||
// 做法是让 C 一次把**所有元素**写进 Go 侧的 span 数组
|
||||
// (Go 预分配、容量按字节数上界估),单趟、无 C 分配。
|
||||
func scanArray(arr strSpan) ([]strSpan, bool) {
|
||||
if !arr.valid() || arr.firstByte() != '[' {
|
||||
return nil, false
|
||||
}
|
||||
// 元素数上界:每个元素至少 1 字节 + 分隔符 ⇒ ≤ 字节数
|
||||
capHint := int(arr.n)
|
||||
if capHint < 4 {
|
||||
capHint = 4
|
||||
}
|
||||
if capHint > 1024 {
|
||||
capHint = 1024 // 工具调用数量级很小;超出部分不可能(协议上界)
|
||||
}
|
||||
spans := make([]C.ha_span, capHint)
|
||||
n := C.go_arr_all(arr.p, arr.n, &spans[0], C.int(capHint))
|
||||
if n < 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]strSpan, 0, int(n))
|
||||
for i := 0; i < int(n); i++ {
|
||||
out = append(out, strSpan{spans[i].p, spans[i].len})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
1
internal/agent/api/ha_sse.c
Symbolic link
1
internal/agent/api/ha_sse.c
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/src/ha_sse.c
|
||||
1
internal/agent/api/ha_sse.h
Symbolic link
1
internal/agent/api/ha_sse.h
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/include/ha_sse.h
|
||||
@ -625,31 +625,41 @@ func normalizeStreamToolCalls(raw []openAIToolCall) []ToolCall {
|
||||
}
|
||||
out := make([]ToolCall, 0, len(raw))
|
||||
for _, tc := range raw {
|
||||
name := tc.Function.Name
|
||||
argsRaw := tc.Function.Arguments
|
||||
if name == "" {
|
||||
name = tc.Name
|
||||
// 仅当顶层 Arguments 存在才用扁平格式;否则保留 function.arguments 嵌套值
|
||||
// (OpenAI 流式续传 chunk:name 不重发但 function.arguments 继续)
|
||||
if tc.Arguments != nil {
|
||||
argsRaw = tc.Arguments
|
||||
}
|
||||
}
|
||||
typ := tc.Type
|
||||
if typ == "" && (tc.ID != "" || name != "" || argsRaw != nil) {
|
||||
typ = "function"
|
||||
}
|
||||
out = append(out, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: typ,
|
||||
Name: name,
|
||||
RawArguments: rawArgsString(argsRaw),
|
||||
StreamIndex: tc.Index,
|
||||
})
|
||||
out = append(out, normalizeStreamToolCall(tc))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeStreamToolCall 是单元素的归一化逻辑。
|
||||
//
|
||||
// ★ 之所以从循环里抽成单元素函数:C 快速路径逐元素处理(而不是整块
|
||||
// unmarshal 成 []openAIToolCall),必须与本函数**共用**同一份归一化逻辑,
|
||||
// 否则两条路径会在「name 回退 / type 补全 / arguments 取哪一份」这些
|
||||
// 条件分支上分叉。抽出后循环与快速路径都调它,结构上无法分叉。
|
||||
func normalizeStreamToolCall(tc openAIToolCall) ToolCall {
|
||||
name := tc.Function.Name
|
||||
argsRaw := tc.Function.Arguments
|
||||
if name == "" {
|
||||
name = tc.Name
|
||||
// 仅当顶层 Arguments 存在才用扁平格式;否则保留 function.arguments 嵌套值
|
||||
// (OpenAI 流式续传 chunk:name 不重发但 function.arguments 继续)
|
||||
if tc.Arguments != nil {
|
||||
argsRaw = tc.Arguments
|
||||
}
|
||||
}
|
||||
typ := tc.Type
|
||||
if typ == "" && (tc.ID != "" || name != "" || argsRaw != nil) {
|
||||
typ = "function"
|
||||
}
|
||||
return ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: typ,
|
||||
Name: name,
|
||||
RawArguments: rawArgsString(argsRaw),
|
||||
StreamIndex: tc.Index,
|
||||
}
|
||||
}
|
||||
|
||||
func parseToolArguments(v interface{}) map[string]interface{} {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
@ -705,64 +715,32 @@ func stringifyContent(v interface{}) string {
|
||||
// 兼容多种 token 用量键名(prompt_tokens/prompt、total_tokens/total 等)
|
||||
// 与 prompt cache 细节字段。返回 false 表示非内容块(纯 usage 心跳等)。
|
||||
func parseOpenAICompatibleStreamChunkFull(data string) (StreamChunk, bool) {
|
||||
var raw struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content interface{} `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
UpstreamUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
PromptCacheHit int `json:"prompt_cache_hit_tokens"`
|
||||
PromptCacheMiss int `json:"prompt_cache_miss_tokens"`
|
||||
PromptTokensDetails *struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||||
return StreamChunk{}, false
|
||||
}
|
||||
|
||||
var usage *TokenUsage
|
||||
pu := raw.UpstreamUsage
|
||||
if pu.Total > 0 || pu.TotalTokens > 0 || pu.Prompt > 0 || pu.PromptTokens > 0 {
|
||||
usage = &TokenUsage{
|
||||
Prompt: pickFirstInt(pu.PromptTokens, pu.Prompt),
|
||||
Completion: pickFirstInt(pu.CompletionTokens, pu.Completion),
|
||||
Total: pickFirstInt(pu.TotalTokens, pu.Total),
|
||||
// ★ C 快速路径(结构导航):定位在 C(零分配、零解码),类型检查与
|
||||
// 需要重新序列化的形态交回 Go 的 encoding/json。
|
||||
//
|
||||
// 契约:必须与 chunkParseGo 对所有输入产出完全相同的结果。
|
||||
// 保证方式见 codec_chunkfast_c.go 顶部:任一环节「不确定」即**整体回退**
|
||||
// chunkParseGo,且拼装/归一化两条路径**共用**同一份代码。
|
||||
//
|
||||
// 为什么保留 Go 实现:它既是回退目标,也是黄金对照的参照实现 ——
|
||||
// 没有它,「C 化没坏」就只是感觉而不是证据。
|
||||
//
|
||||
// ★ 开关:chunkFastEnabled 目前为 false —— 实测本架构比原实现**慢**
|
||||
// (2016ns/20allocs vs 1325ns/13allocs),根因是「5+ 次 cgo 边界
|
||||
// × 每次 ~200ns」吃掉了收益。详见 codec_chunkfast_c.go 的说明与
|
||||
// docs/zh/c-core/sse-codec-c.md §六。改造方向已由天花板实验确认可行。
|
||||
if chunkFastEnabled {
|
||||
if ck, handled, decided := chunkParseFast(data); handled && decided {
|
||||
return ck, true
|
||||
}
|
||||
}
|
||||
return chunkParseGo(data)
|
||||
}
|
||||
|
||||
if len(raw.Choices) == 0 {
|
||||
// 纯 usage 心跳块:有 usage 就透传,否则丢弃
|
||||
if usage != nil {
|
||||
return StreamChunk{Usage: usage}, true
|
||||
}
|
||||
return StreamChunk{}, false
|
||||
}
|
||||
|
||||
choice := raw.Choices[0]
|
||||
ck := StreamChunk{
|
||||
Content: stringifyContent(choice.Delta.Content),
|
||||
ReasoningContent: choice.Delta.ReasoningContent,
|
||||
ToolCalls: normalizeStreamToolCalls(choice.Delta.ToolCalls),
|
||||
Usage: usage,
|
||||
}
|
||||
// finish reason 为空字符串不算终止信号(sensenova 每块都发 "")
|
||||
if choice.FinishReason != nil && *choice.FinishReason != "" {
|
||||
ck.Done = true
|
||||
ck.FinishReason = *choice.FinishReason
|
||||
}
|
||||
return ck, true
|
||||
// parseOpenAICompatibleStreamChunkFullGo 供黄金对照测试直接调原始实现,
|
||||
// 用于验证快速路径与它逐值等价。
|
||||
func parseOpenAICompatibleStreamChunkFullGo(data string) (StreamChunk, bool) {
|
||||
return chunkParseGo(data)
|
||||
}
|
||||
|
||||
// pickFirstInt 返回 a 非零时的 a,否则 b(兼容 *_tokens 与短键名两种 usage 格式)。
|
||||
@ -772,7 +750,6 @@ func pickFirstInt(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// streamHTTPClient 返回专用的流式 HTTP client(懒初始化)。
|
||||
// SSE 长连接不能套整体超时(非流式 180s 会在长流中途报断),
|
||||
// 只保留拨号/握手超时。
|
||||
|
||||
Reference in New Issue
Block a user