20 KiB
记忆系统重构计划 — NoMemory 与 TextCleaner 设计修正
基于 review.md 审查结论,当前实现存在两个设计偏差:
| 机制 | 当前(错误) | 应然(目标) |
|---|---|---|
RegisterTextCleaner |
插件级全局 cleaner,CleanText 入口直接改原文 |
删除,拆为 ToolDef.Cleaner(工具级,仅计算层生效) |
ToolDef.Cleaner |
不存在 | 工具级,仅向量化/jieba/蒸馏时调用,不改原文 |
NoMemory=true |
hasNoMemoryTool 二值判断 → 整轮不写记忆 |
工具输出不参与向量/jieba/蒸馏,但原文保留在 Context/Document |
阶段零:理解当前数据流(现状确认)
用户输入 → processTextInput()
→ context.Append(Input) // evt.Input = CleanText(evt.Input) ← 原文被改
→ a.process() → LLM call loop
→ tool execution → result → msgs ← 工具输出在此
→ context.Append(Response, ToolsUsed) // evt.Response 是 LLM 回复,不是工具输出
→ emitMemoryCandidate(input, response, toolsUsed)
→ main.go goroutine
→ textMem.Append(Event{Input, Response}) // JSONL 原文
→ distiller.Append("assistant", response) // 仅 LLM 回复
→ extractKeyTriples → graph memory
蒸馏心跳:
context.Prune() → ContextToDoc(entries) → Doc.Content ← hasNoMemoryTool 跳过整条
reorgGraph() → docToTriples(Doc) → graph memory
关键发现:工具输出从未直接进入 pipeline(pipeline 只存 user/assistant 的原始文本)。hasNoMemoryTool 跳过了整个 LLM 回复,这是过度保守的。
第一阶段:SDK 定义修改(外部包 homeagent-sdk)
文件: _sdk_local/sdk/plugin.go
1.1 ToolDef 增加 Cleaner 字段
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"` // ← 新增:计算层过滤函数,不改原文
}
Cleaner是函数类型,不序列化(json:"-")- 仅在向量化/jieba/蒸馏等计算环节使用
- 默认 nil = 不过滤
1.2 删除 PluginSDK 中的 TextCleaner
// PluginSDK 中删除:
// textCleaners []func(text string) string // ← 删除
// RegisterTextCleaner() // ← 删除
// TextCleaners() // ← 删除
1.3 相应修改单元测试
文件: _sdk_local/sdk/plugin_test.go
- 删除
TestRegisterTextCleaner、TestTextCleanersEmpty TestToolDefNoMemory保留- 新增
TestToolDefCleaner验证 Cleaner 字段
1.4 go.mod 确认 replace 指令
go.mod 已有 replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local,无需改动。
第二阶段:内部适配层更新
2.1 Registry:删除 textCleaners 聚合
文件: internal/plugin/registry.go
删除项:
- 字段
textCleaners []func(string) string - 字段
CleanText(text string) string方法 buildSDK不再收集 cleaner(cleaner 附着在 ToolDef 上,由 StageHost 管理)loadOne和Load中的r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)移除StopAll中的重置移除
改动点:
| 行号 | 当前 | 改为 |
|---|---|---|
| 87 | textCleaners []func(string) string |
删除 |
| 113-122 | CleanText() 方法 |
删除 |
| 273 | r.textCleaners = append(...) |
删除 |
| 356 | r.textCleaners = append(...) |
删除 |
| 373 | r.textCleaners = nil |
删除 |
2.2 memory/clean_text.go:删除全局 cleaner
文件: internal/memory/clean_text.go
// 删除:
var globalTextCleaner func(string) string // ← 删除
func SetTextCleaner(fn func(string) string) { // ← 删除
globalTextCleaner = fn
}
func CleanText(text string) string {
// if globalTextCleaner != nil { // ← 删除
// text = globalTextCleaner(text) // ← 删除
// } // ← 删除
text = strings.TrimSpace(text)
if text == "" {
return ""
}
text = strings.TrimPrefix(text, ",")
text = strings.TrimPrefix(text, ",")
text = strings.TrimSpace(text)
return text
}
VectorizeClean 保持不变(使用精简后的 CleanText)。
2.3 StageHost:暴露 ToolDef Cleaner 查询
文件: internal/agent/core/stages.go
ToolDef(name) 方法已有,返回 *sdk.ToolDef。由于 ToolDef 现在有 Cleaner 字段,调用方可直接通过 stageHost.ToolDef(name).Cleaner 获取。
新增便捷方法:
func (h *StageHost) ToolDefCleaner(name string) func(string) string {
h.mu.RLock()
defer h.mu.RUnlock()
for _, def := range h.toolDefs {
if def.Name == name {
return def.Cleaner
}
}
return nil
}
// 新增:返回所有 NoMemory 工具名集合,供计算环节跳过
func (h *StageHost) NoMemoryToolNames() map[string]bool {
h.mu.RLock()
defer h.mu.RUnlock()
set := make(map[string]bool, len(h.toolDefs))
for _, def := range h.toolDefs {
if def.NoMemory {
set[def.Name] = true
}
}
return set
}
2.4 StageHost:Import 更新
需 import sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"(已有)。
第三阶段:核心逻辑修正
3.1 context.Append:不再修改原文
文件: internal/agent/core/context.go
func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
// evt.Input = memory.CleanText(evt.Input) // ← 删除:不修改原文
evt.Vector = c.computeVector(&evt) // 向量化仍使用 textForVector
c.events = append(c.events, &evt)
c.save()
}
3.2 textForVector:计算层使用 Cleaner
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string {
text := ""
switch {
case evt.Source == "agent" && evt.Response != "":
text = evt.Response
case evt.Source == "cold_storage":
text = evt.Input + " " + evt.Response
default:
text = evt.Input
}
// 基础清洗(不修改原文)
text = memory.CleanText(text)
// 若 evt 关联了 NoMemory 工具,不再跳过整个事件
// 但若工具注册了 Cleaner,在计算层过滤
// textForVector 是对整个事件的向量化,不拆到工具级别
return text
}
关键变更:
textForVector的入参增加toolDefLookup(通过RelevanceContext.toolDefLookup已有)- 移除
textForVector对memory.CleanText的依赖(因为CleanText不再做插件级过滤) computeVector传入toolDefLookup
3.3 context.Prune:不再跳过 NoMemory 事件
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
// ... 前面的排序逻辑不变 ...
archived := 0
if docStore != nil && len(archive) > 0 {
// 删除 hasNoMemoryTool 跳过整条的逻辑
// for _, s := range archive {
// if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
// continue
// }
// }
// 改为:所有事件都归档,工具级过滤在 ContextToDoc 内部处理
entries := make([]document.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = document.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
if err == nil && doc != nil {
archived = len(entries)
}
}
// ...
}
删除 hasNoMemoryTool 辅助函数(context.go:269-278),迁移到 StageHost 的 NoMemoryToolNames()。
3.4 eventloop:移除 NoMemory 跳过记忆候选
文件: internal/agent/core/eventloop.go
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
// ... 前面的逻辑不变 ...
// 删除 NoMemory 跳过:
// if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
// a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
// }
// 改为:始终 emit,工具过滤在消费端处理
// 但保留 stageCtx.NoMemory(IO 注入的 no_memory flag)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
}
}
同理修改 processMediaInput 中的对应检查。
删除 hasNoMemoryTool 方法(eventloop.go:389-396)。
3.5 main.go:删除 SetTextCleaner
文件: cmd/homed/main.go
// 删除:
// memory.SetTextCleaner(pluginReg.CleanText)
3.6 context.go:textForVector 签名更新
computeVector 需传入 toolDefLookup:
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
}
c.toolDefLookup 已有(通过 SetToolDefLookup 注入)。
第四阶段:文档记忆与蒸馏修正
4.1 document.ContextToDoc:接收 cleaned entries
文件: internal/memory/document/document.go
// ContextToDoc 入参增加 cleanFn,在 summarizeEntries/extractTags/extractEntities 前过滤
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
var parts []string
for _, e := range entries {
// Content 用 cleanFn 过滤后拼接,原文保留
cleaned := e.Content
if cleanFn != nil {
cleaned = cleanFn(e.Content)
}
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, cleaned)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
parts = append(parts, line)
}
// ... 后续不变
}
4.2 context.Prune:传入 cleaner
// 在传 entries 给 ContextToDoc 前,先构建工具→cleaner 映射
toolCleaners := make(map[string]func(string)string)
for _, s := range archive {
for _, name := range s.event.ToolsUsed {
if c.toolDefLookup != nil {
if def := c.toolDefLookup(name); def != nil && def.Cleaner != nil {
toolCleaners[name] = def.Cleaner
}
}
}
}
// 构建清理函数:对所有工具输出依次应用对应 Cleaner
entryCleanFn := func(text string) string {
// 此处 text 是 Content(用户输入),不含工具输出,所以不需要 Cleaner
// Cleaner 在 distill.go 的 docToTriples 中使用
return text
}
实际上,ContextToDoc 中 Content 是用户输入,不包含工具输出。工具输出过滤主要发生在 docToTriples。
4.3 docToTriples:应用 Cleaner
文件: internal/agent/core/distill.go
func docToTriples(doc *document.Doc, toolCleaners map[string]func(string) string) []memory.Triple {
var triples []memory.Triple
if doc == nil {
return triples
}
triples = append(triples, memory.Triple{...})
lines := strings.Split(doc.Content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 应用 Cleaner(如果匹配工具输出行)
// 实际 doc.Content 是 `[时间] source: content → response` 格式,
// 其中 content 是用户输入,不直接包含工具输出
// Cleaner 在此暂不应用,保留后续扩展
terms := memory.CutExact(line)
// ...
}
// ...
}
注意:Doc.Content 中的内容是用户在 Prune 时输入的文本和 LLM 回复,不包含原始工具输出。工具输出在 msgs 中(LLM 对话历史),但不在 Doc.Content 中。因此 docToTriples 不需要直接应用 Cleaner。
第五阶段:NoMemory 语义修正
5.1 NoMemory 的新语义
| 场景 | 旧行为 | 新行为 |
|---|---|---|
emitMemoryCandidate |
hasNoMemoryTool → 跳过 |
始终写入 text memory + pipeline |
context.Prune → ContextToDoc |
hasNoMemoryTool → 跳过 |
全部归档,统一进入文档记忆 |
textForVector |
CleanText 改原文后向量化 |
基础 Trim + 向量化(原文不变) |
docToTriples |
无 Cleaner 直接 jieba | 无 Cleaner 直接 jieba(同理) |
StageContext.NoMemory |
InjectTextNoMemory → 整轮跳过 |
保留(IO 层控制的整轮跳过) |
NoMemory 的声明式语义变为:
NoMemory=true是一个工具元数据标记,当前不在内核层面做特殊跳过- 为后续精确过滤(如管道层跳过 NoMemory 工具的输出)预留标记
- 未来管道增强时可读取此标记,跳过对应工具输出片段
5.2 内置插件标记更新
文件: internal/plugins/cmd/plugin.go
s.RegisterTool("cmd_run", sdk.ToolDef{
Name: "cmd_run",
Description: "执行 Shell 命令...",
NoMemory: true, // 保留:标记工具输出对 LLM 注意力无信号价值
// Cleaner: nil, // 不注册 Cleaner:命令输出噪音不可控
}, p.handleCmdRun)
文件: internal/plugins/agentcli/plugin.go
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
NoMemory: true, // 保留:终端交互噪音
}, p.handleCreate)
// 同理其它 5 个工具
文件: internal/plugins/files/plugin.go(示例插件 _sdk_local/example/files/plugin.go)
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
NoMemory: false, // 明确 false:文件内容对 LLM 注意力有信号价值
Cleaner: func(output string) string {
var r struct{ Content string }
if err := json.Unmarshal([]byte(output), &r); err != nil {
return output
}
return r.Content // 去 JSON 包裹,供未来向量化使用
},
}, p.handleRead)
第六阶段:代码清理
6.1 删除无用代码
| 文件 | 删除内容 |
|---|---|
internal/plugin/registry.go |
textCleaners 字段、CleanText() 方法、相关的 append 逻辑 |
internal/memory/clean_text.go |
globalTextCleaner、SetTextCleaner() |
internal/agent/core/context.go |
hasNoMemoryTool() 辅助函数 |
internal/agent/core/eventloop.go |
hasNoMemoryTool() 方法 |
cmd/homed/main.go |
memory.SetTextCleaner(pluginReg.CleanText) |
6.2 _sdk_local 清理
| 文件 | 操作 |
|---|---|
_sdk_local/sdk/plugin.go |
ToolDef 增 Cleaner;删除 RegisterTextCleaner/TextCleaners/textCleaners |
_sdk_local/sdk/plugin_test.go |
替换 TextCleaner 测试为 Cleaner 测试 |
涉及文件清单
| 文件 | 操作 | 阶段 |
|---|---|---|
_sdk_local/sdk/plugin.go |
ToolDef 增 Cleaner;删 RegisterTextCleaner/TextCleaners |
一 |
_sdk_local/sdk/plugin_test.go |
更新测试 | 一 |
internal/plugin/registry.go |
删 textCleaners + CleanText | 二 |
internal/memory/clean_text.go |
删 globalTextCleaner + SetTextCleaner | 二 |
internal/agent/core/stages.go |
增 ToolDefCleaner + NoMemoryToolNames |
二 |
internal/agent/core/context.go |
Append 不改原文;Prune 不跳 NoMemory;textForVector 入参 toolDefLookup |
三 |
internal/agent/core/eventloop.go |
删 hasNoMemoryTool 调用 + 方法 |
三 |
internal/agent/core/distill.go |
emitMemoryCandidate 不加过滤(已在 eventloop 处理) |
三 |
internal/memory/document/document.go |
ContextToDoc 可选 cleanFn 参数 |
四 |
internal/plugins/cmd/plugin.go |
确认 NoMemory=true | 五 |
internal/plugins/agentcli/plugin.go |
确认 NoMemory=true | 五 |
_sdk_local/example/files/plugin.go |
示例 Cleaner 注册 | 五 |
cmd/homed/main.go |
删 memory.SetTextCleaner(pluginReg.CleanText) |
六 |
实施顺序
阶段一 (SDK 定义) → 阶段二 (内部适配删除) → 阶段三 (核心逻辑)
↓
阶段六 (代码清理) ← 阶段五 (NoMemory 语义) ← 阶段四 (文档记忆)
Step-by-step 实施步骤
_sdk_local/sdk/plugin.go—ToolDef加Cleaner字段;删除RegisterTextCleaner/TextCleaners/textCleaners字段及方法_sdk_local/sdk/plugin_test.go— 更新测试用例internal/plugin/registry.go— 删除textCleaners、CleanText()、收集逻辑internal/memory/clean_text.go— 删除globalTextCleaner、SetTextCleanerinternal/agent/core/stages.go— 增ToolDefCleaner、NoMemoryToolNamesinternal/agent/core/context.go—Append: 删除evt.Input = memory.CleanText(evt.Input)textForVector: 入参加toolDefLookupcomputeVector: 传入c.toolDefLookupPrune: 删除hasNoMemoryTool跳过逻辑- 删除
hasNoMemoryTool辅助函数 load(): 不再对已加载事件调用CleanText
internal/agent/core/eventloop.go—processTextInput:emitMemoryCandidate前删!a.hasNoMemoryTool(toolsUsed)条件processMediaInput: 同上- 删除
hasNoMemoryTool方法
internal/memory/document/document.go—ContextToDoc入参加cleanFncmd/homed/main.go— 删除memory.SetTextCleaner(pluginReg.CleanText)- 内置插件 — 确认 NoMemory 标记,示例插件加 Cleaner
- 编译测试 —
go build ./...确认无编译错误
验证方法
编译检查
go build ./...
go vet ./...
单元测试
# SDK 测试
cd _sdk_local && go test ./sdk/...
# 内核测试
cd /home/program/TrueAgent && go test ./internal/agent/core/...
go test ./internal/memory/...
go test ./internal/plugin/...
行为验证
场景 1:NoMemory 工具调用后记忆仍然产生
- 用户输入:"查一下 /etc/passwd"
- Agent 调用
cmd_run(NoMemory=true) - LLM 回复:"第一行是 root,uid=0,超级管理员哦~"
- ✅
textMem.Append(Event{Response: "第一行是 root..."})— 写入 - ✅
distiller.Append("assistant", "第一行是 root...")— 写入 - ✅
context.Append(ContextEvent{Response: "第一行是 root..."})— 可见
场景 2:Cleaner 仅影响计算层
- Agent 调用
files_read返回{"content": "敏感数据"} - ✅ 原文
msgs中保留{"content": "敏感数据"} - ✅ LLM 回复中可见原始内容
textForVector使用 Cleaner 提取content字段(注:实际 textForVector 对 Response 操作,Response 已是 LLM 的自然语言,不是 JSON 包裹。Cleaner 在工具输出进入 msgs 时注释即可。)
实际上,Cleaner 的调用时机需要斟酌。工具输出通过 executeToolCallInner 返回字符串,进入 msgs 中的 role: "tool" 消息。msgs 用于 LLM 上下文,不需要 Cleaner。Cleaner 是在"工具输出单独进入记忆计算"时才需要。
当前架构中,工具输出不单独进入记忆计算,而是通过 LLM 的回复间接影响记忆。所以 Cleaner 的实际用途是预留扩展:当未来有直接对工具输出进行向量化/摘要的环节时,使用 Cleaner 过滤。
回归测试
- 确认旧有
InjectTextNoMemory(IO 层整轮跳过,通过stageCtx.NoMemory控制)不受影响 - 确认 context prune 不再因 NoMemory 工具跳过低相关性事件归档
- 确认 text memory 日志完整记录所有对话轮次