Files
HomeAgent/internal/agent/api/codec_cgo.go
JianFeeeee 7351c6ca2e feat(c-core): 内核编解码层 C 化第一刀 —— L1 纯函数层落地并打通构建链
第一刀只做三个纯函数(窗口推断 / token 估算 / token 截断),
价值不在功能(Go 版没问题),而在打通「Go → cgo → C」全链路并
建立可复现的对照范式,后面每扩一个函数都复用它。

## 为什么是这三个

按「无状态 → 有状态」分层,L1 协议编解码最安全:纯 string in → struct out,
不碰网络、不碰 Lua、不碰 goroutine。三个函数更是同一组纯算术,最小可验证切片。

## 关键设计:包内符号链接,不链接静态库、不 include 包外源

三种做法都实测过,只有一种同时满足「可构建 + 可交叉编译 + 缓存可跟踪」:

1. ❌ 链接 `csrc/build/libha_codec.a`(原方案)
   - .a 是构建产物、不入库(.gitignore 的 build/ 命中 csrc/build/),
     而发布脚本原先并不产出它 ⇒「不入库 + 不生成」两头空,
     实测报 `cannot find .../libha_codec.a`
   - 交叉编译 linux/arm64(homed 真实发布目标)时,宿主 x86-64 的 .a
     被链进目标产物,实测报 `file in wrong format`

2. ❌ `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)
   ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**。实测:包外源把返回值
   7→8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内
   文件时立即判红。对「逐步推进 C 化」这是致命的——改 C 源码不生效且无报错。
   (包内 shim `#include` 包外源同样漏跟踪,已实测排除。)

3. ✅ 包内符号链接 `internal/agent/api/ha_codec.{c,h}` → `csrc/`
   文件在包目录内 ⇒ 缓存按内容正确跟踪;只有一份权威源 ⇒ 无副本漂移,
   也不需要「同步 C 源」的 make 目标。

## 不需要额外 build tag

ha_codec 是零依赖纯 C99 源码内联编译,不需要外部库或工具链前提;
而 homed 本就强制 cgo(sqlite3 + gojieba),故 C 路径自然生效。
只用 `cgo` / `!cgo` 一组约束(对比 onnxruntime:那个需运行期 .so,故必须显式 tag)。

## 顺带修掉的既有缺陷(非 C 化引入,但一直缺覆盖)

- Makefile 的 arm64 目标缺 CC/CXX:cgo 回退到宿主 g++,报
  `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'`
  (deploy/packaging/build.sh:49 一直是对的,Makefile 漏了)
- Makefile 的 arm64 目标缺 .syso 隔离:cmd/{homed,waiter}/*.syso 是 Windows
  COFF 资源对象,Go 会把同目录 .syso 无条件链进任何目标,交叉到非 Windows
  平台报 `file format not recognized`(build.sh 有 hide_syso_for_target)

## 验证(每条可复现)

- `make build-linux-arm64` → ELF 64-bit LSB executable, ARM aarch64(82MB)
- `bash deploy/packaging/build.sh linux/arm64 homed` → ELF aarch64(79MB)
- 移走 csrc/build/ 后 homed(cgo)与 waiter(CGO=0)均能构建
- 变异 C 源(131072→777)后**同一缓存**下 go test 立即 FAIL(改前:仍报 ok)
- `make check-codec-paths` 两条路径 OK;`make csrc-test` C 契约测试 100%
- 黄金对照:手写用例 + 2000 次随机对拍,C 与纯 Go 逐值相等
- 全量 `go test -count=1 ./...` → 57 包:38 ok + 19 无测试 + 0 FAIL

新增 `make check-codec-paths` 防回归:只测一条路径时,另一条的破坏不会被发现。

## 文档

- `docs/zh/c-core/llm-orchestration-c.md` 同步为「已落地」,并更正因
  「Windows 原生已放弃」而过时的 §2.3(回退路径的理由需重述)
- plan.md 的 P0-1 标记为已修复,附实测证据
2026-09-25 14:45:08 +08:00

98 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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/),
// 而发布脚本原先并不产出它 ⇒「不入库 + 不生成」两头空,链接必然失败
// (实测:cannot find csrc/build/libha_codec.a)
// - 交叉编译 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 目标。
//
// ============================ 为什么不需要额外 build tag ============================
// 与 onnxruntime(internal/nlp/onnx.go,需运行期 libonnxruntime.so)不同:
// ha_codec 是**零依赖纯 C99 源码内联编译**,不需要任何外部库或工具链前提。
// 而 homed 本就强制 cgo(mattn/go-sqlite3 + gojieba),故 C 路径自然生效。
// 因此只用 `cgo` / `!cgo` 一组约束,不引入 hacodec tag。
//
// ============================ C 侧契约 ============================
// `#include "ha_codec.h"` 只声明原型;实现在同包的 ha_codec.c,由 cgo 自动编译。
// 只含 libc 头,不引入第三方符号。
//
// 语义必须与 codec_pure.go 逐值等价,由 codec_golden_test.go 钉死。
/*
#cgo CFLAGS: -std=c99
#include <stdlib.h>
#include "ha_codec.h"
*/
import "C"
import "unsafe"
// modelContextWindowC 经 C 实现推断上下文窗口。
func modelContextWindowC(model string) int {
cModel := C.CString(model)
defer C.free(unsafe.Pointer(cModel))
return int(C.ha_codec_model_context_window(cModel))
}
// estimateTokensC 经 C 实现估算 token 数。
func estimateTokensC(text string) int {
cText := C.CString(text)
defer C.free(unsafe.Pointer(cText))
return int(C.ha_codec_estimate_tokens(cText))
}
// truncateByTokensC 经 C 实现按 token 截断。
//
// 缓冲区策略:按 rune 数上界分配(每个 rune 最多 4 字节)+ 1 字节 NUL,
// 保证 C 侧不会因容量不足而截短——否则 C 与 Go 的逐值对照会假失败。
// 若字符串无 rune(纯 ASCII 也至少 len 字节),取 len(text)+1 兜底。
func truncateByTokensC(s string, maxTokens int) string {
if maxTokens <= 0 || s == "" {
return ""
}
// []rune 的长度即 rune 数;每个 rune 最坏 4 字节,+1 给 NUL。
runeCount := len([]rune(s))
bufSize := runeCount*4 + 1
if bufSize < len(s)+1 {
bufSize = len(s) + 1
}
buf := (*C.char)(C.malloc(C.size_t(bufSize)))
if buf == nil {
// 分配失败:回退纯 Go 实现,不让整个调用失败。
return truncateByTokensPure(s, maxTokens)
}
defer C.free(unsafe.Pointer(buf))
cText := C.CString(s)
defer C.free(unsafe.Pointer(cText))
n := C.ha_codec_truncate_by_tokens(cText, C.int(maxTokens), buf, C.size_t(bufSize))
return C.GoStringN(buf, C.int(n))
}