mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
C 化第二刀:为协议编解码层铺 JSON 底座。**本刀只交付库 + 验收,
未改 Go 生产路径**(接线是独立一步,库先验完再换产线)。
为什么是它:SSE 单块解析(parseOpenAICompatibleStreamChunkFull)是每个流式
chunk 都要跑的最热路径,实测 1937ns/13allocs(content 块)、3122ns/21allocs
(toolcall 块),而纯字节扫描理论下限 133ns/1alloc —— 差距 15~23×。
一次 1 万块的会话 = 1~2 万次堆分配,正是 GC 抖动的来源。
为什么不复用 SDK 的 remotedevice/ha_json.c(实测三缺陷,不可直接复用):
① 无 \u 解码:\u4f60\u597d → ?0?d?d?0(非 ASCII 全靠转义时内容直接损坏)
② 只有 _get_int 无浮点:temperature:0.7 静默变 0
③ null 与「键缺失」不可区分
外加它是 DOM + malloc,与本层「不 malloc / 零拷贝 / 纯函数」正交。
设计:scan(结构,零分配零解码)+ extract(取值,按需解码)两段分离。
content 可能是很大的多模态数组,而 stringifyContent 只需要 text 字段拼起来;
若 scan 就解码并分配缓冲,等于把成本付给不需要它的调用方。
★ 被测试抓出 7 个真实缺陷(写 C 时同一逻辑我读三遍都认为正确):
1 代理对合成成功后未跳过 unconditionally 的 U+FFFD 发射(😀 → 两个 FFFD)
2 过长编码检查用了只含首字节位的 cp(「你」→ 6 个 FFFD)
3 members_next 只报值起点不消费值 → 游标停在值前(模糊测试第一轮抓到)
4 扫描阶段不校验转义字符合法性({"a":"\q"} C 判合法、json.Valid=false)
5 扫描阶段不校验 \u 后四位十六进制(同上)
6 get_int 接受前导零(007 / 00)
7 cgo 桥接把 C 结构体声明为 Go 局部变量 → 运行时 panic
(cgo argument has Go pointer to unpinned Go pointer)
其中 4 个是「静默分叉」——不崩、不报错,生产里表现为「内容少一个字符」
或「某些块被静默丢弃」,极难归因。这正是黄金对照不可省的理由。
★ 另纠正我自己两次错误的「真值」(比代码 bug 更危险,会变成错误规格):
第一版真值表里 content:{} 的花括号少了一层,把「我写错 JSON」误读成
「Go 对 content 严格」。修正后实测发现一对方向相反的语义:
content 走 interface{} 宽松({}→"{}"、true→"true"),
reasoning_content/usage/finish_reason 强类型严格(123 ⇒ 整块作废)。
照错误表写 C 会产出「比 Go 更严格」的实现,静默丢弃本该生效的块。
两个由缺陷倒逼的设计决定:
- members_next 返回**完整值 span** 并内部跳过 ⇒ 「返回 1」蕴含「成员良构」。
要求调用方自己推进游标的 API 是错的:忘一次就解析到上一个值且不报错。
- members_complete() 区分「正常扫到 }」与「输入畸形」,否则无法复刻 Go 严格性。
同时修两个基础设施目标对「多源文件/多测试」的适配:
- csrc-sanitize:每个契约测试各自链接(多个 main 合链会 multiple definition,
而报错被吞后会被误报成「本机无 sanitizer」——一个假的 SKIP)
- csrc-cross:多源文件改用 -fsyntax-only 逐文件(gcc 不支持多源单 -o)
实测(全部当场可复现):
- C 契约测试 119 项断言全过;黄金对照 5 组全过(语法/成员/解码/整数/随机字节)
- libFuzzer 4948 万次运行零崩溃(121s)
- ASan+UBSan PASS(两个契约测试各跑);gcc+clang 零告警;arm64 交叉编译 0 告警
- 全量 go test -count=1 ./... 0 FAIL;make build-linux-arm64 → ELF aarch64
- 纪律检查 SDK 公开接口 diff = 0 行(未触碰 SDK)
决策关闭(jianf 本轮裁决):C 实现留主仓 csrc/(它本就是替换内核 Go 实现,
SDK 从未被触碰,跨端复用才需进 SDK 而它们不调用本层);ha_json.c 不复用;
下一刀即协议编解码层。
185 lines
8.8 KiB
Go
185 lines
8.8 KiB
Go
//go:build cgo
|
||
|
||
package api
|
||
|
||
// codec_cgo.go —— 编解码层的 C 实现绑定(CGO_ENABLED=1 时参与编译)。
|
||
//
|
||
// ============================ 架构:包内符号链接 ============================
|
||
// C 源是 `ha_codec.c` / `ha_codec.h`,它们是**指向 csrc/ 的符号链接**
|
||
// (`ln -s ../../../csrc/src/ha_codec.c`):
|
||
//
|
||
// internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c
|
||
// internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h
|
||
//
|
||
// 权威源只有一份(csrc/),Go 侧看到的是包目录内的链接。
|
||
//
|
||
// ============================ 为什么不用另外两种做法 ============================
|
||
//
|
||
// **不能链接预构建静态库**(`LDFLAGS: .../csrc/build/libha_codec.a`):
|
||
// - .a 是构建产物、不入库(.gitignore 的 build/ 命中 csrc/build/),
|
||
// 而发布脚本原先并不产出它 ⇒「不入库 + 不生成」两头空,链接必然失败
|
||
// - 交叉编译 linux/arm64(homed 的真实发布目标)时,宿主 x86-64 的 .a
|
||
// 被链进目标产物,报 `file in wrong format`
|
||
//
|
||
// **不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)**:
|
||
// ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**。实测:包外源把返回值
|
||
// 7 改成 8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在
|
||
// 包内文件时立即判红。这对「逐步推进 C 化」是致命的——改 C 源码却不生效
|
||
// 且无任何报错。
|
||
// (包内 shim `#include` 包外源同样漏跟踪,已实测排除。)
|
||
//
|
||
// 包内符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪;
|
||
// 只有一份权威源 ⇒ 无副本漂移,也不需要「同步 C 源」的 make 目标。
|
||
//
|
||
// ============================ 零拷贝:不 CString、不 strlen ============================
|
||
// ★ 这是**被实测教训倒逼出来的**(见 docs/zh/c-core/llm-orchestration-c.md §7.1):
|
||
//
|
||
// cgo 边界的固有成本实测约 **32 ns**(零拷贝传指针 + 空函数体)。
|
||
// 而初版每次调用都做 `C.CString`(malloc + 整串拷贝)+ C 侧 `strlen`(再扫一遍),
|
||
// 单这一项就约 **75 ns**,加上 C 侧 `lower_dup` 的 malloc 与逐字节扫描,
|
||
// 使 ModelContextWindow 实测达到 **175 ns** —— 即 **82% 是自找的开销**,
|
||
// 而非 cgo 的固有代价。初版由此得出「C 比 Go 慢」的结论是**错的**。
|
||
//
|
||
// 现在:Go 侧用 `unsafe.StringData` 把 string 的底层字节**直接**交给 C
|
||
// (传指针 + 长度),C 侧不 malloc、不 strlen、不要求 NUL 结尾。
|
||
// 截断则只回**字节长度**(结果必然是输入前缀),Go 侧 `s[:n]` 完成切片,
|
||
// 全程零分配零拷贝。
|
||
//
|
||
// 边界与安全:
|
||
// - 不把 Go 指针交给 C 长期持有(C 侧不保存任何指针,纯函数)
|
||
// - 空串在 Go 侧短路,不把可能的 nil 指针传下去
|
||
// - cgo 规则允许传「不含 Go 指针的内存」的指针,string 底层字节满足
|
||
//
|
||
// ============================ 为什么不需要额外 build tag ============================
|
||
// 与 onnxruntime(internal/nlp/onnx.go,需运行期 libonnxruntime.so)不同:
|
||
// ha_codec 是**零依赖纯 C99 源码内联编译**,不需要任何外部库或工具链前提。
|
||
// 而 homed 本就强制 cgo(mattn/go-sqlite3 + gojieba),故 C 路径自然生效。
|
||
// 因此只用 `cgo` 约束(**没有 `!cgo` 回退**:CGO_ENABLED=0 下本包构建失败,
|
||
// 这是有意的响亮失败,理由见上),也不引入 hacodec tag。
|
||
//
|
||
// 语义必须与 codec_pure.go 逐值等价,由 codec_golden_test.go 钉死。
|
||
|
||
/*
|
||
#cgo CFLAGS: -std=c99
|
||
#include <stdlib.h>
|
||
#include "ha_codec.h"
|
||
|
||
// 下面这个常量就是 C 侧宏展开后的值,经由 cgo 暴露给 Go。
|
||
//
|
||
// ★ 声明成 C 函数(而非 const)才能从 Go 侧读到值:
|
||
// cgo 生成的 `*_Cvar_*` 变量对 Go 而言**不是常量**(实测报
|
||
// "is not constant"),所以 Go 侧拿它做不了编译期断言,
|
||
// 只能在测试期当普通变量比对。编译期的保证由下面那条 C 断言提供。
|
||
int ha_abi_version_macro(void) { return HA_CODEC_ABI_VERSION; }
|
||
|
||
// C 侧自检:宏合成式与主/次版本必须自洽。
|
||
// 这条断言在**编译 C 时**就生效,而不是等 Go 侧测试跑到。
|
||
_Static_assert(HA_CODEC_ABI_MAJOR * 1000 + HA_CODEC_ABI_MINOR == HA_CODEC_ABI_VERSION,
|
||
"ha_abi.h: HA_CODEC_ABI_VERSION 合成式与主/次版本不一致");
|
||
*/
|
||
import "C"
|
||
|
||
import "unsafe"
|
||
|
||
// codecABIVersionExpected 是 C 侧 ha_abi.h 里 HA_CODEC_ABI_VERSION 的 Go 副本。
|
||
//
|
||
// ★ 为什么要手工拄一份而不是让 cgo 直接读宏:
|
||
// cgo 顶部的 C 代码在 cgo 阶段被**预处理并丢弃**,其中的宏在 Go 侧不可见;
|
||
// 能看到的只有 cgo 生成的文件。用 cgo 的 `const` 桥接(C.ha_codec_abi_version)只能在
|
||
// **运行期**问到版本,编译期拿不到,无法把「两侧版本不一致」变成构建失败。
|
||
// 而「注释里说冻结」不是机制。这份 Go 常量 + codec_abiversion_test.go
|
||
// 把版本漂移变成**测试期断言**,真正对得上才跑得起来。
|
||
//
|
||
// 改动规则(与 ha_abi.h 一致):
|
||
// - C 侧新增函数/枚举值(纯追加)→ 同步把这里 +1,并改 abi_test 的期望
|
||
// - 改签名/删函数/改结构体布局 → MAJOR+1,**所有调用方必须同步重编**
|
||
const (
|
||
codecABIMajorExpected = 1
|
||
codecABIMinorExpected = 0
|
||
)
|
||
|
||
// codecABIVersionMacroValue 查询 C 侧 HA_CODEC_ABI_VERSION 宏展开后的值。
|
||
//
|
||
// ★ 它的存在是为了堵一个盲区:TestABIVersionMatches 比的是
|
||
// 「Go 常量 vs C 函数返回值」。若有人同时把 Go 常量和 C 函数
|
||
// 一起改掉(而忘了改 ha_abi.h 的宏),那条测试照样通过 ——
|
||
// **两边一起错成一样**是它的盲区。这个值直接取自 C 宏,
|
||
// 由 codec_abimacro_test.go 拿来交叉核对。
|
||
//
|
||
// ★ 为何是函数而非 Go 常量:cgo 生成的 `*_Cvar_*` 不是 Go 常量
|
||
// (实测 "is not constant"),无法在编译期参与断言。
|
||
// 编译期的保证在 C 侧(codec_cgo.go 里的 _Static_assert)。
|
||
func codecABIVersionMacroValue() int { return int(C.ha_abi_version_macro()) }
|
||
|
||
// codecABIVersion 查询 C 侧自称的 ABI 版本(major*1000 + minor)。
|
||
func codecABIVersion() int { return int(C.ha_codec_abi_version()) }
|
||
|
||
|
||
// cstr2 与 cstr 同义(返回 Go 的 string 版本),供 cgo 桥接层使用。
|
||
// 名字不同是为了与测试文件里的辅助函数区分,避免包内重名。
|
||
func cstr2(s string) (*C.char, C.size_t) { return cstr(s) }
|
||
|
||
// cstrb 取字节切片的首地址(供 C 侧写入目标缓冲)。
|
||
func cstrb(b []byte) *C.char {
|
||
if len(b) == 0 {
|
||
return nil
|
||
}
|
||
return (*C.char)(unsafe.Pointer(&b[0]))
|
||
}
|
||
|
||
// cstrp 返回 Go string 的底层字节首地址(不做空串短路,供
|
||
// 「长度已知、可能为空」的取值场景使用)。
|
||
func cstrp(s string) *C.char {
|
||
if len(s) == 0 {
|
||
return nil
|
||
}
|
||
return (*C.char)(unsafe.Pointer(unsafe.StringData(s)))
|
||
}
|
||
|
||
// cstr 返回 s 的底层字节首地址与长度,供 C 侧零拷贝读取。
|
||
//
|
||
// 空串返回 (nil, 0):调用方不应把 nil 传给会解引用的 C 函数。
|
||
func cstr(s string) (*C.char, C.size_t) {
|
||
if len(s) == 0 {
|
||
return nil, 0
|
||
}
|
||
return (*C.char)(unsafe.Pointer(unsafe.StringData(s))), C.size_t(len(s))
|
||
}
|
||
|
||
// modelContextWindowC 经 C 实现推断上下文窗口。
|
||
func modelContextWindowC(model string) int {
|
||
p, n := cstr(model)
|
||
return int(C.ha_codec_model_context_window(p, n))
|
||
}
|
||
|
||
// estimateTokensC 经 C 实现估算 token 数。
|
||
//
|
||
// ★ 不做按长度分派:**完全 C 化**——compute 一律走 C,纯 Go 实现不再是
|
||
// 生产路径(只作为黄金对照的规格基准)。
|
||
//
|
||
// 代价(如实记录,勿用「C 更快」一句话盖过):cgo 边界固有成本实测约 30ns,
|
||
// 故对「极短串」(如 2 字节的 "qq")本函数约 30ns,而直调纯 Go 仅约 3ns
|
||
// ——即极短输入上 C 路径约慢一个数量级,但绝对值是**纳秒级**
|
||
// (30ns = 0.00003ms,单次请求尺度可忽略)。
|
||
// 换来的是:单一实现、无静默分派分叉、C 侧对畸形 UTF-8 的严格校验恒生效。
|
||
func estimateTokensC(text string) int {
|
||
p, n := cstr(text)
|
||
return int(C.ha_codec_estimate_tokens(p, n))
|
||
}
|
||
|
||
// truncateByTokensC 经 C 实现按 token 截断。
|
||
//
|
||
// C 侧只返回「应保留的字节数」——截断结果必然是输入的前缀,
|
||
// 故这里直接切片,无需缓冲区、无需 malloc、无需把结果拷回来。
|
||
func truncateByTokensC(s string, maxTokens int) string {
|
||
if maxTokens <= 0 || s == "" {
|
||
return ""
|
||
}
|
||
p, n := cstr(s)
|
||
keep := C.ha_codec_truncate_by_tokens(p, n, C.int(maxTokens))
|
||
if uint64(keep) >= uint64(len(s)) {
|
||
return s
|
||
}
|
||
return s[:int(keep)]
|
||
}
|