Files
HomeAgent/internal/agent/core/context_tuning_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

253 lines
10 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.

package core
import (
"testing"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// 这组判据把「上下文管理的阈值」从硬编码变成可配置,并守住两条纪律:
//
// 1. **零值等价**:ContextTuning 零值必须与硬编码时代**逐值相同**。
// 否则升级内核就等于悄悄改了所有实例的上下文策略。
// 2. **没有隐形断层**:写死 0.8 与 600000 两个常量会合成一个没人能一眼看出的
// 分段函数 —— 窗口 ≤750k 时工作面是 80%,>750k 时退化成 600000/window。
// 现在拐点必须是**显式配置**,而不是两个常量相乘的副产品。
// windowProvider 已在 tokenbudget_test.go 声明(字段 n,值接收者)——
// 直接复用,不重复定义(同名类型重复声明会让整包编译失败)。
// legacyBudget 复刻**硬编码时代**的算法(0.8 / 600000 / available/3)。
// 判据拿它当规格基准 —— 与 C 侧 golden 测试用参考实现当基准同一思路。
func legacyBudget(maxCtx, fixed int) TokenBudget {
if maxCtx <= 0 {
maxCtx = 32768
}
targetUsage := int(float64(maxCtx) * 0.8)
if targetUsage > 600000 {
targetUsage = 600000
}
reserved := maxCtx - targetUsage
if reserved < 0 {
reserved = 0
}
available := targetUsage - fixed
if available < 0 {
available = 0
}
mem := available / 3
return TokenBudget{
MaxContext: maxCtx,
TargetUsage: targetUsage,
FixedTokens: fixed,
MemoryTokens: mem,
ContextTokens: available - mem,
Reserved: reserved,
}
}
// TestContextTuningZeroValueEqualsLegacy 是**升级安全**判据。
//
// 覆盖小窗口、200k(本次要测的目标)、以及 600k/750k/1M(断层两侧)。
func TestContextTuningZeroValueEqualsLegacy(t *testing.T) {
for _, w := range []int{0, 8192, 32768, 131072, 200000, 262144, 600000, 750000, 1048576, 2000000} {
p := windowProvider{n: w}
got := ComputeTokenBudgetTuned(p, "", ContextTuning{})
want := legacyBudget(w, 0)
if got != want {
t.Errorf("窗口 %d:零值 tuning 与硬编码不一致\n got=%+v\nwant=%+v", w, got, want)
}
}
}
// TestContextTuningUtilizationPercent 验利用率可调(阈值 1)。
func TestContextTuningUtilizationPercent(t *testing.T) {
p := windowProvider{n: 200000}
// 80% ⇒ 160000(与历史一致)
if got := ComputeTokenBudgetTuned(p, "", ContextTuning{UtilizationPercent: 80}).TargetUsage; got != 160000 {
t.Errorf("80%% 时 TargetUsage=%d,期望 160000", got)
}
// 50% ⇒ 100000
if got := ComputeTokenBudgetTuned(p, "", ContextTuning{UtilizationPercent: 50}).TargetUsage; got != 100000 {
t.Errorf("50%% 时 TargetUsage=%d,期望 100000", got)
}
// 95% ⇒ 190000
if got := ComputeTokenBudgetTuned(p, "", ContextTuning{UtilizationPercent: 95}).TargetUsage; got != 190000 {
t.Errorf("95%% 时 TargetUsage=%d,期望 190000", got)
}
}
// TestContextTuningMaxTargetUncappedRemovesKink 是本轮的核心动机。
//
// 历史行为:窗口 1M 时工作面只有 600000(≈60%),因为被 600000 封顶;
// 而窗口 700k 时工作面是 560000(80%)。同一个「0.8」在两侧给出不同利用率。
//
// MaxTargetTokens < 0 ⇒ 不封顶 ⇒ 利用率对所有窗口都真的等于配置值。
func TestContextTuningMaxTargetUncappedRemovesKink(t *testing.T) {
const uncapped = -1
// 1M 窗口、80% ⇒ 838860(而不是被截到 600000)
got := ComputeTokenBudgetTuned(windowProvider{n: 1048576}, "",
ContextTuning{UtilizationPercent: 80, MaxTargetTokens: uncapped})
if got.TargetUsage != 838860 {
t.Errorf("不封顶时 1M 窗口 TargetUsage=%d,期望 838860(80%%)", got.TargetUsage)
}
// 关键:不封顶后,利用率必须是**常数**(这才是"按总上下文自动调整")。
for _, w := range []int{200000, 400000, 600000, 750000, 1048576, 2000000} {
b := ComputeTokenBudgetTuned(windowProvider{n: w}, "",
ContextTuning{UtilizationPercent: 70, MaxTargetTokens: uncapped})
want := w * 70 / 100
if b.TargetUsage != want {
t.Errorf("窗口 %d:70%% 不封顶时 TargetUsage=%d,期望 %d(利用率应为常数)",
w, b.TargetUsage, want)
}
if b.Reserved != w-want {
t.Errorf("窗口 %d:Reserved=%d,期望 %d", w, b.Reserved, w-want)
}
}
// 而历史默认(封顶 600000)在 1M 窗口上仍然是 600000 —— 未被改变。
hist := ComputeTokenBudgetTuned(windowProvider{n: 1048576}, "", ContextTuning{})
if hist.TargetUsage != 600000 {
t.Errorf("默认仍应封顶 600000,实际 %d", hist.TargetUsage)
}
}
// TestContextTuningMemoryRatio 验记忆/事件的切分可调(阈值 2)。
func TestContextTuningMemoryRatio(t *testing.T) {
p := windowProvider{n: 200000}
// 可用预算 = 160000 - 0 = 160000
// 自动(0)⇒ 1/3 = 53333(与历史一致)
auto := ComputeTokenBudgetTuned(p, "", ContextTuning{})
if auto.MemoryTokens != 160000/3 {
t.Errorf("自动记忆预算=%d,期望 %d", auto.MemoryTokens, 160000/3)
}
if auto.MemoryTokens+auto.ContextTokens != 160000 {
t.Errorf("切分不守恒:%d + %d != 160000", auto.MemoryTokens, auto.ContextTokens)
}
// 手动 50% ⇒ 80000 / 80000
half := ComputeTokenBudgetTuned(p, "", ContextTuning{MemoryRatioPercent: 50})
if half.MemoryTokens != 80000 || half.ContextTokens != 80000 {
t.Errorf("50%% 记忆 ⇒ mem=%d ctx=%d,期望 80000/80000",
half.MemoryTokens, half.ContextTokens)
}
// 手动 20% ⇒ 32000 / 128000
fifth := ComputeTokenBudgetTuned(p, "", ContextTuning{MemoryRatioPercent: 20})
if fifth.MemoryTokens != 32000 || fifth.ContextTokens != 128000 {
t.Errorf("20%% 记忆 ⇒ mem=%d ctx=%d,期望 32000/128000",
fifth.MemoryTokens, fifth.ContextTokens)
}
// 任意比例都必须守恒(不能因为取整丢 token)
for _, pct := range []int{1, 7, 33, 49, 51, 66, 93, 99} {
b := ComputeTokenBudgetTuned(p, "", ContextTuning{MemoryRatioPercent: pct})
if b.MemoryTokens+b.ContextTokens != 160000 {
t.Errorf("%d%% 时不守恒:%d + %d != 160000",
pct, b.MemoryTokens, b.ContextTokens)
}
if b.MemoryTokens < 0 || b.ContextTokens < 0 {
t.Errorf("%d%% 时出现负预算:mem=%d ctx=%d", pct, b.MemoryTokens, b.ContextTokens)
}
}
}
// TestContextTuningProtectedCount 验裁剪时保留的最近条数可调(阈值 3)。
//
// 这一条影响的是**记忆召回**:Prune 永远保留最近 N 条,其余按相关性淘汰。
// N 越大,越不容易丢近处信息;N 越小,越依赖向量检索的准确度。
//
// ⚠️ 两条踩过的坑,都写进判据里:
//
// 1. 必须给**真实** embedder:Prune 用它算相关性,nil 会空指针。
// 生产不会遇到(New() 在 cfg.Embedder 为 nil 时兜底建一个)。
// 2. 保留条数的上界是 **max(topK, protectedCount)**,不是 topK。
// 「无条件保留最近 N 条」优先于 topK —— 这是**既有契约**
// (硬编码时代的 pCount=10 在 12 事件/topK=3 下同样保留 10)。
// 本判据第一版错写成「≤ topK」,被实测纠回来:
// 实现没变,是我的断言写了实现从未有过的契约。
//
// 默认值下(protectedCount=10 << topK≈29)两者不会冲突,
// 所以这个交互只在用户把保护条数调得很大时才会显现。
func TestContextTuningProtectedCount(t *testing.T) {
emb := memory.NewStaticEmbedder()
// 常规:protectedCount(3) < topK(5) ⇒ 总保留 = 保护 3 + 按相关选 2 = 5
rc := NewRelevanceContext("", emb)
rc.protectedCount = 3
for i := 0; i < 20; i++ {
rc.Append(ContextEvent{Source: "t", Input: "无关内容", Response: "无关回复"})
}
rc.Prune("查询", 5, nil)
if got := rc.Len(); got != 5 {
t.Errorf("protectedCount=3/topK=5:裁剪后条数=%d,期望 5(保护 3 + 相关 2)", got)
}
// 边界:protectedCount(50) > topK(3) ⇒ 保护优先,且不得 panic。
// pCount 被夹到 len(events)=12 ⇒ candidates 空 ⇒ 直接不裁 ⇒ 留 12。
rc2 := NewRelevanceContext("", emb)
rc2.protectedCount = 50
for i := 0; i < 12; i++ {
rc2.Append(ContextEvent{Source: "t", Input: "x", Response: "y"})
}
rc2.Prune("q", 3, nil) // 不能 panic
if got := rc2.Len(); got != 12 {
t.Errorf("protectedCount=50/topK=3:条数=%d,期望 12(保护优先,全部保留)", got)
}
// 保护条数可调必须**真的生效**:同一个输入,不同 protectedCount 给出不同结果。
// 这是本判据的核心 —— 否则「可配置」只是说说。
small := NewRelevanceContext("", emb)
small.protectedCount = 2
big := NewRelevanceContext("", emb)
big.protectedCount = 6
for i := 0; i < 20; i++ {
ev := ContextEvent{Source: "t", Input: "同样内容", Response: "同样回复"}
small.Append(ev)
big.Append(ev)
}
small.Prune("查询", 8, nil)
big.Prune("查询", 8, nil)
if small.Len() != 8 || big.Len() != 8 {
// topK=8 且 protectedCount 都 ≤ 8 ⇒ 两者都应收敛到 8
t.Errorf("topK=8 时 small=%d big=%d,期望都是 8(topK 未被保护条数突破)",
small.Len(), big.Len())
}
}
// TestAgentCarriesContextTuning 是**接线**判据。
//
// 存在的理由:本仓刚发现 `core.agent.max_context_size` 被注册进配置面板、
// 也有默认值,但**从来没有人把它读进 AgentConfig** —— 一个"死配置",
// 界面上改它没有任何效果,且不报任何错。所以这里断言 tuning 真的到了 Agent 上。
func TestAgentCarriesContextTuning(t *testing.T) {
a := New(AgentConfig{
ID: "t", Provider: windowProvider{n: 200000},
ProviderManager: agentAPI.NewProviderManager(),
CtxTuning: ContextTuning{
UtilizationPercent: 70,
MaxTargetTokens: -1,
MemoryRatioPercent: 25,
ProtectedCount: 7,
},
})
b := a.computeTokenBudget()
// 窗口 200000、70% ⇒ 工作面 140000(-1 不封顶)
if b.TargetUsage != 140000 {
t.Errorf("Agent 未带上 UtilizationPercent:TargetUsage=%d,期望 140000",
b.TargetUsage)
}
// 25% 记忆 ⇒ mem=35000、ctx=105000
if b.MemoryTokens != 35000 || b.ContextTokens != 105000 {
t.Errorf("Agent 未带上 MemoryRatioPercent:mem=%d ctx=%d,期望 35000/105000",
b.MemoryTokens, b.ContextTokens)
}
gotProtected := -1
if a.context != nil {
gotProtected = a.context.protectedCount
}
if gotProtected != 7 {
t.Errorf("Agent 未把 ProtectedCount 传到上下文裁剪:protectedCount=%d,期望 7",
gotProtected)
}
}