Files
HomeAgent/internal/agent/core/tokenbudget.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

85 lines
3.3 KiB
Go
Raw Permalink 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.

package core
import (
"gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// TokenBudget 上下文 token 预算分配结果
type TokenBudget struct {
MaxContext int // 模型窗口上限
TargetUsage int // 目标使用量(受 maxTargetTokens 与 utilizationRate 共同约束)
FixedTokens int // 固定部分(system prompt base + tools + rules)
MemoryTokens int // memory context 可用预算
ContextTokens int // 上下文事件可用预算
Reserved int // 预留(response 空间)
}
// EstimateTokens / TruncateByTokens 转发到编解码层统一出口。
//
// 本包内调用点很多(context.go / process.go / resident.go / tooldefs.go…),
// 而实现只有一份(api 包,C 化后可选走 C)。保留这两个同名转发,
// 是为了不把调用点全部改写成 api.EstimateTokens —— 那场改动对行为零收益,
// 却把「本包依赖 api」这件事铺得到处都是。
// EstimateTokens 粗略估算 token 数(转发到 api.EstimateTokens)。
func EstimateTokens(text string) int { return api.EstimateTokens(text) }
// TruncateByTokens 截断字符串至不超过 maxTokens 估计值(转发到 api.TruncateByTokens)。
func TruncateByTokens(s string, maxTokens int) string { return api.TruncateByTokens(s, maxTokens) }
// ComputeTokenBudget 计算各部分的 token 预算。
//
// maxTargetTokens 是**有效工作区间**的上限(不是模型窗口)。
//
// 为什么窗口 1M 却不能按 800K 干活:标称窗口 ≠ 有效窗口。接近满窗口时注意力
// 明显涣散、成本与延迟也随 prompt 线性上升。该源(llmsproxy)实测 990,034 token
// 仍能返回,但 600K 才是它的最优工作区间 —— 超过这个量级,回答质量与延迟都不划算。
//
// 因此把「窗口上限」(判断请求会不会被上游拒)与「工作区间」(分配记忆/历史预算)
// 分开:前者由 provider.MaxContextTokens() 给,后者封顶在这里。
const maxTargetTokens = 600000
// ComputeTokenBudget 计算各部分的 token 预算
// utilizationRate 为目标窗口利用率(0.0-1.0),预留 1-utilizationRate 给 response
// 固定部分优先保障,剩余预算 1:2 分配给 memory context 和 context events
func ComputeTokenBudget(provider api.Provider, systemPromptBase string) TokenBudget {
maxCtx := provider.MaxContextTokens()
if maxCtx <= 0 {
maxCtx = 32768
}
utilizationRate := 0.8
targetUsage := int(float64(maxCtx) * utilizationRate)
// 窗口很大(如 1M)时不要把 80% 当成工作面:按 maxTargetTokens 封顶。
if targetUsage > maxTargetTokens {
targetUsage = maxTargetTokens
}
reserved := maxCtx - targetUsage
if reserved < 0 {
reserved = 0
}
fixedTokens := EstimateTokens(systemPromptBase)
available := targetUsage - fixedTokens
if available < 0 {
available = 0
}
// memory context 占 1/3,context events 占 2/3
memTokens := available / 3
ctxTokens := available - memTokens
return TokenBudget{
MaxContext: maxCtx,
TargetUsage: targetUsage,
FixedTokens: fixedTokens,
MemoryTokens: memTokens,
ContextTokens: ctxTokens,
Reserved: reserved,
}
}
// TruncateByTokens 已移至 api 包(编解码层统一出口,见 codec.go)。
// 上方已有同名转发,此处不再重复定义。