mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
fix(lightkernel): 传统上下文落到实处 —— 子不做策略性裁剪(并修掉裁剪估算的字节/rune 单位混用)
用户指出实现不自洽:"子 agent 是传统上下文,没有裁剪"。查证属实:子仍走 `formatMergedTimeline` 的预算裁剪、也可能走 `pruneOnInput`(既裁剪又向 doc 记忆归档)。 即"动态上下文"(父专属能力)漏进了轻量内核。 ## 三处闸门(按 isLightKernel() 判) 1. **拼装预算**:新增 `contextTokenBudget(b)` —— 父用动态上下文的 `ContextTokens`, 子用**整个窗口** `MaxContext`。两个调用点都改(`stepPrepare`、`rebaseFramePrefix`; 漏掉后者时 prepare 之后的重建仍会裁,实测就是这么被抓出来的)。 2. **pruneOnInput**:轻量内核前置返回 —— 不做按相关度裁剪、不向 doc 记忆归档。 3. doc 记忆 / 整理流水线:早已由 `a.memory == nil` 关闭(N2c)。 ## "不裁"的准确含义 不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个硬上限约束; 且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ⇒ **丢事件的决定权在父**(压缩/回收/销毁),不在内核。文档 §16.0.0 记录三处闸门表。 ## 顺带修掉的既有 bug(单位混用) `formatMergedTimeline` 逐事件估算原来是 `len(e.Source)+len(e.Input)+40` 再 ×2 —— `len()` 是**字节**,而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍: 实测 2384 字的中文事件被估成 14398 token > 8192,于是窗口还有余量也提前 break、 把更早的事件整段丢掉。改为统一走 `EstimateTokens`(对父同样生效:中文长会话不再被过早裁剪)。 ## 测试 - 新增 `TestLightKernel_TraditionalContextNoTrimming`:用**记录模型实收消息**的 provider 断言"更早的事件仍在"(为什么不看 TaskFrame:`prepareInputTask` 只做前半段, 消息在 `runTaskSteps`/`stepPrepare` 才拼出来 —— 我第一版断言就打在了空帧上); 填充量取"超过动态份额、但仍在窗口内",从而能区分父/子两种行为。 - 新增 `TestFullKernel_StillUsesDynamicContext` 作对照(父仍用动态份额)。 全量 go test ./... 37 包 ok / 0 FAIL;-race 干净。
This commit is contained in:
@ -456,6 +456,25 @@ type GraphMemory interface {
|
||||
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||||
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
|
||||
|
||||
### 16.0.0 传统上下文的实现口径(子 vs 父)
|
||||
|
||||
"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判):
|
||||
|
||||
| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 |
|
||||
|---|---|---|---|
|
||||
| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53%) | **整个窗口** `budget.MaxContext` | `contextTokenBudget()`(`stepPrepare` 与 `rebaseFramePrefix` 两处) |
|
||||
| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 |
|
||||
| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` |
|
||||
|
||||
**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个
|
||||
**硬上限**约束;而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ——
|
||||
**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。
|
||||
|
||||
**顺带修掉的既有 bug**:`formatMergedTimeline` 逐事件估算原用 `len()`(**字节**)再 ×2,
|
||||
而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、
|
||||
把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192)。已改为统一的
|
||||
`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。
|
||||
|
||||
### 16.0.1 工具面(已实现)
|
||||
|
||||
| 工具 | 谁用 | 作用 |
|
||||
|
||||
@ -370,6 +370,11 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if a.context == nil || !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||||
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||||
if a.isLightKernel() {
|
||||
return 0
|
||||
}
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
|
||||
@ -20,6 +20,29 @@ package core
|
||||
|
||||
import "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
|
||||
// isLightKernel 报告本 agent 是不是**轻量内核**(驻留子)。
|
||||
//
|
||||
// 轻量内核 = 传统上下文 + 图记忆(作用域化):它**没有**动态上下文能力
|
||||
// (预算裁剪 / 按相关度裁剪并向 doc 记忆归档),那些是父 agent 专属的。
|
||||
func (a *Agent) isLightKernel() bool { return a != nil && a.parentID != "" }
|
||||
|
||||
// contextTokenBudget 返回拼装时间线时可用的 token 预算。
|
||||
//
|
||||
// 完整内核:用动态上下文算出来的 ContextTokens(按相关度/预算**策略性**裁时间线)
|
||||
// 轻量内核:**用整个窗口** —— 传统上下文只受"模型能收多少"这个**硬上限**约束,
|
||||
// 不做任何策略性裁剪(不按相关度挑、不向 doc 记忆归档);
|
||||
// 而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent
|
||||
// 决策(压缩/回收/销毁)—— 丢事件的决定权在父,不在内核。
|
||||
func (a *Agent) contextTokenBudget(b TokenBudget) int {
|
||||
if a.isLightKernel() {
|
||||
if b.MaxContext > 0 {
|
||||
return b.MaxContext
|
||||
}
|
||||
return defaultMaxContextTokens
|
||||
}
|
||||
return b.ContextTokens
|
||||
}
|
||||
|
||||
// GraphMemory 是任意 agent 都能用的图记忆面。
|
||||
//
|
||||
// 实现者:
|
||||
|
||||
@ -331,11 +331,13 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
|
||||
include := 0
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
e := events[i]
|
||||
est := len(e.Source) + len(e.Input) + 40
|
||||
// ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2:
|
||||
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break,
|
||||
// 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。
|
||||
estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40
|
||||
if e.Response != "" {
|
||||
est += 120
|
||||
estTokens += 120
|
||||
}
|
||||
estTokens := est * 2
|
||||
if remaining-estTokens < 0 && include > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
@ -5,11 +5,13 @@ package core
|
||||
// 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -377,3 +379,96 @@ func TestResident_E2EAndStress(t *testing.T) {
|
||||
t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报",
|
||||
nResidents, roundsEach)
|
||||
}
|
||||
|
||||
// ---- 传统上下文:轻量内核不做动态上下文的裁剪 ----
|
||||
|
||||
// captureProvider 记录模型**实际收到**的消息。
|
||||
//
|
||||
// 为什么不直接看 TaskFrame:`prepareInputTask` 只做前半段(去重/通道/阶段/落上下文),
|
||||
// 消息是在 `runTaskSteps` 的 stepPrepare 里才拼出来的;而且断言"模型看到了什么"
|
||||
// 本来就比断言内核内部字段更接近事实。
|
||||
type captureProvider struct {
|
||||
countingProvider
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
messages []agentAPI.Message
|
||||
}
|
||||
|
||||
func (p *captureProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
if req != nil {
|
||||
p.messages = append([]agentAPI.Message(nil), req.Messages...)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (p *captureProvider) chatText() (int, string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var b strings.Builder
|
||||
for _, m := range p.messages {
|
||||
b.WriteString(m.Content)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return p.calls, b.String()
|
||||
}
|
||||
|
||||
// 子 agent 的上下文是**传统上下文**:累积的事件全部交给模型,
|
||||
// 内核**不得**按动态上下文的预算静默丢弃(那是父 agent 的能力)。
|
||||
// 装不下时由 contextfull 上报父决策,而不是自己丢。
|
||||
func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
|
||||
provider := &captureProvider{}
|
||||
parent, _, dir := newRootWith(t, provider)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
|
||||
if !child.isLightKernel() {
|
||||
t.Fatal("驻留子必须被识别为轻量内核")
|
||||
}
|
||||
|
||||
// 前提:动态上下文的份额 < 窗口(否则测不出区别)。
|
||||
b := ComputeTokenBudget(child.provider, child.systemPrompt)
|
||||
if b.MaxContext <= b.ContextTokens {
|
||||
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
|
||||
}
|
||||
// 填充量:**超过动态份额、但仍在窗口内**。
|
||||
// ⇒ 完整内核会因预算把最早那条裁掉;轻量内核不该裁(只受窗口硬上限约束)。
|
||||
filler := strings.Repeat("填", b.ContextTokens/2+200)
|
||||
if EstimateTokens(filler) <= b.ContextTokens {
|
||||
t.Fatalf("测试前提不成立:填充(%d token) 应超过动态份额(%d)", EstimateTokens(filler), b.ContextTokens)
|
||||
}
|
||||
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最早的事件标记EARLY"})
|
||||
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: filler})
|
||||
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最新的事件标记LATE"})
|
||||
|
||||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"})
|
||||
|
||||
waitFor(t, "子发出 LLM 请求", func() bool {
|
||||
n, _ := provider.chatText()
|
||||
return n >= 1
|
||||
})
|
||||
_, got := provider.chatText()
|
||||
if !strings.Contains(got, "最早的事件标记EARLY") {
|
||||
t.Fatalf("传统上下文:更早的事件不得被预算裁掉(属于父 agent 的动态上下文能力);实收消息长度=%d", len(got))
|
||||
}
|
||||
if !strings.Contains(got, "最新的事件标记LATE") {
|
||||
t.Fatal("最新事件必须在内")
|
||||
}
|
||||
}
|
||||
|
||||
// 对照:完整内核(根 agent)仍走动态上下文(按预算裁时间线)。
|
||||
func TestFullKernel_StillUsesDynamicContext(t *testing.T) {
|
||||
parent, _, _ := newRootForResidents(t)
|
||||
if parent.isLightKernel() {
|
||||
t.Fatal("根 agent 不是轻量内核")
|
||||
}
|
||||
b := ComputeTokenBudget(parent.provider, "sys")
|
||||
if got := parent.contextTokenBudget(b); got != b.ContextTokens {
|
||||
t.Fatalf("完整内核应使用动态上下文的 ContextTokens(%d),实际 %d", b.ContextTokens, got)
|
||||
}
|
||||
if b.MaxContext <= b.ContextTokens {
|
||||
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
|
||||
}
|
||||
}
|
||||
|
||||
@ -273,7 +273,7 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
|
||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||
prefix := a.buildMessages(sysPrompt, f.Input, budget.ContextTokens)
|
||||
prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||
|
||||
// 重建会丢掉 prepare 段对尾部消息的两处改写,这里等价地补回。
|
||||
if f.IsInterrupt && len(prefix) > 0 {
|
||||
@ -497,7 +497,7 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
|
||||
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||
f.Tools = a.buildToolDefs()
|
||||
|
||||
f.Msgs = a.buildMessages(sysPrompt, f.Input, budget.ContextTokens)
|
||||
f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
||||
if a.interruptInput {
|
||||
last := f.Msgs[len(f.Msgs)-1]
|
||||
|
||||
Reference in New Issue
Block a user