feat(sdk): RecallPolicy —— 声明工具输出是否触发记忆召回

与 ContextPolicy **正交**,但默认值刻意相反:

| | 管什么 | 默认 |
|---|---|---|
| ContextPolicy | **裁剪**:把低相关 L0 事件归档 | 关(剪裁是破坏性的,须显式声明) |
| RecallPolicy | **召回**:把 L2/L3 相关记忆注入本轮 | 输入/注入 `auto`,**工具 `none`** |

工具默认 none 的理由:多数工具输出是噪声,据它召回会把无关记忆拉进来
(源码注释原文)。需要「取回真实内容后据它召回」的工具才显式声明 auto。

## qq 的落地(本改动想解决的具体问题)

qq 通道到达的是**中断通知(meta)**而不是用户正文。原先用这条 meta 文本
去触发召回 —— 那是无关词,召不回真正相关的东西。改为:

- 通道声明 `RecallPolicy: none`(meta 不该据它召回)
- `qq_get_message` 声明 `auto`:消息正文取回后**由正文**触发召回
- `qq_get_history` 同样 `auto` + `prune`:拉回的历史消息既用完即裁、
  又据正文召回(否则有「记忆里有、但拉历史时不注入」的盲区)

注:本实现已先随主仓 vendored 副本进入 main(两份经 diff 校验字节一致,
0 行差异),此处是把 SDK 仓自身补齐,使两仓 HEAD 对齐。
This commit is contained in:
JianFeeeee
2026-09-20 09:15:34 +08:00
parent db207fd9b7
commit 7717bf5ca5
2 changed files with 48 additions and 3 deletions

View File

@ -439,7 +439,9 @@ type 枚举: text文字/ voice语音转文字后发送/ image
}
return cleaned
}
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
// qq 通道到达的是**中断通知meta**,不是用户正文,不据它召回;
// 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone})
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
cleaner := func(output string) string {
@ -459,6 +461,9 @@ type 枚举: text文字/ voice语音转文字后发送/ image
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
// 长会话下持续挤占 token 预算§13.8)。
ContextPolicy: "prune",
// 正文才是真实内容:取回后用**正文**触发一次召回,
// 而不是用中断通知的 meta 文本去召回(那是无关词)。
RecallPolicy: "auto",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
@ -484,6 +489,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name可用 qq_download_file 工具下载。",
NoMemory: false,
Cleaner: cleaner,
// 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要,
// 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占
// L0又据正文召回取进来。不声明 recall 的话就是「记忆里有、但
// 拉回历史消息时不注入」的盲区。
ContextPolicy: "prune",
RecallPolicy: "auto",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
@ -1482,6 +1493,8 @@ func (p *Plugin) injectInterrupt(text, level string) {
p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{
NoMemory: true,
Priority: level,
// 中断文本是路由/取正文的指令,不是对话内容,不据它召回。
RecallPolicy: sdk.RecallPolicyNone,
})
}
@ -2802,7 +2815,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
// Priority同上QQ 侧一律低级别中断L1
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone})
}
} else {
errMsg = "下载失败,文件可能已过期"

View File

@ -54,6 +54,26 @@ func ValidContextPolicy(policy string) bool {
return false
}
// 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
//
// 与 ContextPolicy **正交**ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
// RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
// 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
// 默认 auto输入/注入),仅**工具**默认 none工具输出多为噪声按需声明
const (
RecallPolicyNone = "none"
RecallPolicyAuto = "auto"
)
// ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
func ValidRecallPolicy(policy string) bool {
switch policy {
case "", RecallPolicyNone, RecallPolicyAuto:
return true
}
return false
}
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
//
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
@ -65,6 +85,7 @@ func ValidContextPolicy(policy string) bool {
//
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
// RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto召回
//
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
//
@ -77,7 +98,11 @@ func ValidContextPolicy(policy string) bool {
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
// RecallPolicy 声明此次注入是否据其内容召回相关记忆。
// 空串 = 默认(输入/注入 auto即保持既有「每条输入都召回」的行为
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
RecallPolicy string
CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
//
@ -106,6 +131,7 @@ const (
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none不裁剪
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto召回
//
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshalfunc 不支持),又会诱使调用方手写字段白名单——
@ -114,6 +140,8 @@ type ChannelDef struct {
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
// RecallPolicy 见 InjectOptions.RecallPolicy空串等价 auto保持既有行为
RecallPolicy string `json:"recall_policy,omitempty"`
}
// StageContext provides context for stage handlers.
@ -180,6 +208,10 @@ type ToolDef struct {
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
// RecallPolicy 声明此工具输出是否触发一次记忆召回(注入)。
// ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。
// 默认 none多数工具输出是噪声需要「取回真实内容后据它召回」的工具如 qq_get_message应显式声明 auto。
RecallPolicy string `json:"recall_policy,omitempty"`
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.