fix(memory): L1 恢复为 provider 原生消息序列

## 设计口径(用户裁定,2026-11-03)

	「我的设计是 l1 渲染出来就是传统式的上下文排列,
	  难怪指代消解能力这么差」

## 之前错在哪

buildMessages 把**全部历史事件拼成一条 system 消息**:

	system: 系统提示词
	system: [Conversation timeline]
	        [15:04:05] 用户: 帮我找月租<400的房子
	        [15:04:05] 助手: ...
	        [15:04:05] 工具(qq_get_message): ...
	user:   当前输入

说话人身份靠**文本前缀**表达。模型对它拿到的是"背景资料"式的注意力
结构,而不是"我说过的对话"——指代消解因此失效(用户回"新乡"接不上
上一句"帮我找月租<400…")。

★ 0f98f36 那次只改了**文本渲染格式**(把日志样式换成对话样式),
  底层"一条 system 承载全部历史"的结构一动没动,所以问题还在。

## 现在的形态

	system     系统提示词
	user       帮我找一套月租<400 水电常价带独卫的单间
	assistant  好的,我去找。
	user       新乡
	assistant  新乡这边有几套。
	user       当前输入

工具轮按协议展开:

	user       帮我看看天气
	assistant  (content + tool_calls=[c1,c2])
	tool       (tool_call_id=c1) 晴 26 度
	tool       (tool_call_id=c2) 他说新乡
	user       继续

实现要点:
- 新增 renderL1Messages:一个 ContextEvent 展开为
  user → assistant(calls) → tool... 消息;
- tool_call 与 tool 按 CallID 严格配对(CallID 优先,缺失退化到工具名);
- InactivePages 剔除**完整的工具页**:assistant 上对应的 tool_call 与
  紧随的 tool 消息一起消失,绝不留下"有 call 无 result"(上游必 400);
- 超长工具结果仍渲染为句柄(toolresult_read 取回);
- 保留 timelineUserText 的中断轮处理:从聊天工具回执里取真实原话,
  不让注入样板冒充用户发言;
- 保留 token 预算裁剪,事件级按消息粒度计入。

## 清理(无生产调用方的旧渲染路径)

删除:
- formatMergedTimeline(旧 system-timeline 唯一生产者)
- renderedToolResultsOf(旧截断风格渲染,已被原生序列取代)
- timelineToolLines(仅上面两者使用)
- timelineUserLabel / timelineAssistantLabel
- toolResultTimelineChars / toolResultTimelineCharsChat /
  toolResultTimelineMax / isChatCorpusTool
删除对应已失效判据:timeline_contract_test.go、timeline_toolresult_test.go

保留 toolResultPageIndex:overflow.go 的 eventTokens 仍在用,
且统计侧与渲染侧必须共用同一份配对规则(否则"渲染少了、统计没少"
会让裁剪永远裁不动,2026-11-02 实测事故)。

## 判据(新增,均已变异验证)

l1_native_sequence_test.go:
- TestL1RendersNativeMessageSequence
    断言 role 序列 system/user/assistant/user/assistant/user,
    且 system 消息中不得再出现 "[Conversation timeline]" 或 "用户:"
- TestL1ExpandsToolCallsAsPairedMessages
    断言 assistant.ToolCalls[i].ID 与紧随 tool.ToolCallID 一一配对
- TestL1InactivePageDropsWholePair
    剔除页时 call 与 result 必须同时消失

变异验证:把实现退回旧 system-timeline ⇒ 三条全红,恢复后全绿。

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	gofmt -l internal/             → 干净
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
This commit is contained in:
JianFeeeee
2026-11-03 14:37:53 +08:00
parent 81a0f5f598
commit 24b03e56e9
5 changed files with 371 additions and 468 deletions

View File

@ -524,97 +524,6 @@ func (a *Agent) coldcontextStoreSize() int {
return 0
}
func (a *Agent) formatMergedTimeline(maxTokens int) string {
a.context.mu.Lock()
events := make([]*ContextEvent, len(a.context.events))
copy(events, a.context.events)
a.context.mu.Unlock()
if len(events) == 0 {
return ""
}
// 第一轮:从最新到最旧,计算在预算内能放多少条
headerTokens := EstimateTokens("[Conversation timeline]\n")
remaining := maxTokens - headerTokens
include := 0
for i := len(events) - 1; i >= 0; i-- {
e := events[i]
// ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2:
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break,
// 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。
// 预算按**实际渲染出来的内容**算:用户行 + 助手全文 + 展开的工具行。
estTokens := EstimateTokens(timelineUserText(e)) + EstimateTokens(e.Response) + 40
for _, line := range a.timelineToolLines(e) {
estTokens += EstimateTokens(line)
}
if remaining-estTokens < 0 && include > 0 {
break
}
remaining -= estTokens
include++
}
if include == 0 && len(events) > 0 {
include = 1
}
// 第二轮:按时间正序渲染
start := len(events) - include
if start < 0 {
start = 0
}
var sb strings.Builder
// 头部自带语义说明(**块自我描述**):此前这句解释写在人格/系统提示词里,
// 提示词一精简就丢语义 ⇒ 模型把历史摘要当成当前待办。说明跟数据贴在一起
// 才不会漂移。
sb.WriteString("[Conversation timeline] Summaries of past events follow (**not the current task**; do not treat their content as a new request from the user).\n")
for _, e := range events[start:] {
// ★ L1 是工作记忆区,送给模型的是**时序排列的对话**。
// 历史教训(2026-10-27):这里曾被渲染成"摘要 + 工具管道"
// ("—/ → 调用工具: qq_get_message, output_send__qq → 已回复…"),
// 主体是注入样板与工具名,真正的话被压成行尾碎片 ⇒ 指代消解失效
// (用户回"新乡",模型接不上上一句"帮我找一套月租<400…")。
sb.WriteString(fmt.Sprintf("[%s] %s: %s\n",
e.Timestamp.Format("15:04:05"), timelineUserLabel, timelineUserText(e)))
if resp := oneLine(e.Response); resp != "" {
sb.WriteString(fmt.Sprintf("[%s] %s: %s\n",
e.Timestamp.Format("15:04:05"), timelineAssistantLabel, resp))
}
// 工具结果在 context 中**展开**(全文,不截断)。
// 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。
//
// ★ 段页式淘汰(P5):InactivePages 里的页不渲染,但在**原位**留一行提示。
//
// "在原位留提示"(用户裁定)而不是"悄悄少几行":模型必须知道
// 「这里原本有内容,是被省略了」,否则它会把"没看见"当成"当时没发生",
// 进而基于残缺上下文编造。这也是项目一贯的"不静默降级"原则。
//
// 提示里带上页序号与恢复手段:模型可据此用 coldcontext_query 找回。
lines := a.timelineToolLines(e)
pageOf := toolResultPageIndex(e)
dropped := 0
for i, line := range lines {
if len(e.InactivePages) > 0 && i < len(pageOf) && containsInt(e.InactivePages, pageOf[i]) {
dropped++
continue
}
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.Timestamp.Format("15:04:05"), line))
}
if dropped > 0 {
sb.WriteString(fmt.Sprintf("[%s] (本帧有 %d 个工具往返因超出上下文预算被省略;"+
"内容仍在记忆中,可用 coldcontext_query 按需找回)\n",
e.Timestamp.Format("15:04:05"), dropped))
}
}
return sb.String()
}
// 时间线(L1)的两个说话人标签。
const (
timelineUserLabel = "用户"
timelineAssistantLabel = "助手"
)
// timelineUserText 取这一轮**用户真正说的话**。
//
// 两个来源:
@ -625,6 +534,50 @@ const (
//
// 这一条决定了指代消解能否成立:模型必须看到上一句**原话**,
// 而不是"我调用过 qq_get_message"。
// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。
//
// 页序约定:第 1 页是 llm_round(用户输入 ↔ 模型输出),
// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对
// (退化到工具名),因此并行调用时位置未必一一对应,必须按配对键判定。
//
// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages,
// 即"配不上页就不剔除"—— 宁可多渲染,不可猜错)。
//
// ★ 统计侧(overflow.go 的 eventTokens)与渲染侧(renderL1Messages)
// 必须用**同一个**页索引函数:配对规则只能有一份,否则"渲染少了、
// 统计没少"会让裁剪永远裁不动(2026-11-02 实测事故)。
func toolResultPageIndex(e *ContextEvent) []int {
if e == nil || len(e.ToolResults) == 0 {
return nil
}
out := make([]int, len(e.ToolResults))
for i := range e.ToolResults {
out[i] = 0
for j := range e.ToolCalls {
tc := &e.ToolCalls[j]
hit := false
if tc.CallID != "" && e.ToolResults[i].CallID != "" {
hit = tc.CallID == e.ToolResults[i].CallID
} else {
hit = tc.Name == e.ToolResults[i].Name
}
if hit {
out[i] = j + 2 // 第 1 页是 llm_round
break
}
}
}
return out
}
// oneLine 把历史正文压成单行;原生 L1 消息仍保留 role 边界,
// 这里只处理单条 content 内的换行,不再把多条消息拼成 system 日志。
func oneLine(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.Join(strings.Fields(s), " ")
}
func timelineUserText(e *ContextEvent) string {
if e == nil {
return ""
@ -663,181 +616,104 @@ func chatUtteranceFromResults(trs []ToolResultItem) string {
return ""
}
// timelineToolLines 渲染一条事件里的工具结果行。
//
// 契约(2026-10-27 用户口径):
// - 工具结果在 context 里**展开**(全文,不截断、不限条数);
// - 只有**超长**结果(已落盘进 toolresult_store)折叠成句柄,
// 模型用 toolresult_read(event_id, offset, limit) 按区间读回。
//
// 为什么不能截断普通结果:截断会让模型拿到残缺信息且不知情(本仓
// "静默降级"同族),而历史里工具结果正是消解指代的依据。
// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。
//
// 页序约定与 document.docBlocksForEntry 一致:第 1 页是 llm_round,
// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对,
// 因此这里用同样的配对顺序(并行调用时位置未必一一对应)。
//
// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages,
// 即"配不上页就不剔除"—— 宁可多显示,不可猜错)。
func toolResultPageIndex(e *ContextEvent) []int {
if e == nil || len(e.ToolResults) == 0 {
return nil
}
out := make([]int, len(e.ToolResults))
for i := range e.ToolResults {
out[i] = 0
for j := range e.ToolCalls {
tc := &e.ToolCalls[j]
hit := false
if tc.CallID != "" && e.ToolResults[i].CallID != "" {
hit = tc.CallID == e.ToolResults[i].CallID
} else {
hit = tc.Name == e.ToolResults[i].Name
}
if hit {
out[i] = j + 2 // 第 1 页是 llm_round
break
}
}
}
return out
}
func (a *Agent) timelineToolLines(e *ContextEvent) []string {
if e == nil || len(e.ToolResults) == 0 {
return nil
}
noMem := map[string]bool(nil)
if a != nil && a.stageHost != nil {
noMem = a.stageHost.NoMemoryToolNames()
}
stored := make(map[string]ToolResultStore, len(e.ToolResultStores))
for _, st := range e.ToolResultStores {
stored[st.Tool] = st
}
out := make([]string, 0, len(e.ToolResults))
for _, tr := range e.ToolResults {
if noMem[tr.Name] {
continue
}
if st, ok := stored[tr.Name]; ok {
out = append(out, fmt.Sprintf("工具(%s): [stored %d bytes / %d lines — read with toolresult_read(event_id=%q)]",
tr.Name, st.TotalBytes, st.TotalLines, e.ID))
continue
}
txt := tr.Output
if a != nil {
txt = a.toolOutputForQuery(tr.Name, txt) // Cleaner(计算层清洗,不改原文)
}
if txt = oneLine(txt); txt != "" {
out = append(out, fmt.Sprintf("工具(%s): %s", tr.Name, txt))
}
}
return out
}
// renderedToolResults 把一条事件里的工具回执渲染成时间线片段。
//
// 排序即执行顺序;单条截断 toolResultTimelineChars,最多 toolResultTimelineMax 条
// (更早的只留"还有 N 条"),避免一次 bulk 查询把时间线挤爆。
const (
// toolResultTimelineChars 单条工具回执在时间线里的字符上限。
// 时间线是"给下一轮看的历史",不是当轮工作区 —— 够让模型认出
// "我查到了什么",细节仍可用 coldcontext_query / 工具重取。
toolResultTimelineChars = 160
// toolResultTimelineCharsChat 是**对话正文类**工具(qq_get_message /
// qq_get_history)的放宽值(2026-10-27):群聊上下文被切到 160 字后只剩碎片,
// 模型看得到"有人说过平板",看不到"平板→promax→机型→随机发货"这条链。
toolResultTimelineCharsChat = 400
// toolResultTimelineMax 单条事件最多渲染几条工具回执。
toolResultTimelineMax = 3
)
// isChatCorpusTool 报告该工具的输出是否属于"对话正文"(需要更长的渲染)。
//
// 判定用后缀而不是全名:工具名带插件前缀(qq_get_message / telegram_get_message…),
// 而语义是"取聊天正文"这一族。
func isChatCorpusTool(name string) bool {
for _, suffix := range []string{"_get_message", "_get_history", "_list_chats", "_search_messages"} {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
// oneLine 压成单行(时间线是一行一条事件,换行会破坏结构)。
func oneLine(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.Join(strings.Fields(s), " ")
}
func (a *Agent) renderedToolResultsOf(e *ContextEvent) []string {
if e == nil || len(e.ToolResults) == 0 {
return nil
}
noMem := map[string]bool(nil)
if a != nil && a.stageHost != nil {
noMem = a.stageHost.NoMemoryToolNames()
}
// ★ 超长结果不渲染正文,只给**句柄**(2026-10-27 用户口径:
// 渲染工具回执,但超长的才不渲染 —— 它们已经在工具环里)。
//
// 为什么不能渲染它的预览:预览本身是多行文本,塞进时间线会把历史压成
// "半截结果",而模型真正需要的是「知道有这么一份东西、能从哪儿读」。
// 正文按需用 toolresult_read(event_id, offset, limit) 取,信息不丢。
stored := make(map[string]ToolResultStore, len(e.ToolResultStores))
for _, st := range e.ToolResultStores {
stored[st.Tool] = st
}
out := make([]string, 0, len(e.ToolResults))
skipped := 0
for _, tr := range e.ToolResults {
if noMem[tr.Name] {
continue
}
if st, ok := stored[tr.Name]; ok {
out = append(out, fmt.Sprintf("%s: [stored %d bytes / %d lines — read with toolresult_read(event_id=%q)]",
tr.Name, st.TotalBytes, st.TotalLines, e.ID))
continue
}
txt := tr.Output
if a != nil {
txt = a.toolOutputForQuery(tr.Name, txt) // ← Cleaner 生效
}
txt = oneLine(txt)
if strings.TrimSpace(txt) == "" {
continue
}
if len(out) >= toolResultTimelineMax {
skipped++
continue
}
limit := toolResultTimelineChars
if isChatCorpusTool(tr.Name) {
limit = toolResultTimelineCharsChat
}
out = append(out, fmt.Sprintf("%s: %s", tr.Name, truncateStr(txt, limit)))
}
if skipped > 0 {
out = append(out, fmt.Sprintf("+%d more tool results", skipped))
}
return out
}
func (a *Agent) buildMessages(sysPrompt, input string, ctxTokens int) []agentAPI.Message {
msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}}
if ctxTok := a.formatMergedTimeline(ctxTokens); ctxTok != "" {
msgs = append(msgs, agentAPI.Message{Role: "system", Content: ctxTok})
}
// L1 必须保持 provider 原生的消息序列:历史 user/assistant/tool
// 各自使用真实 role,而不是把整段对话拼成一条 system 日志。
// 这对指代消解尤其重要:模型能直接看到上一条 user,而不是从
// "[时间] 用户:" 这样的文本标签里猜说话人。
msgs = append(msgs, a.renderL1Messages(ctxTokens)...)
msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
return msgs
}
// renderL1Messages 把 L1 事件渲染成传统的 provider 消息序列。
//
// 一个事件按协议展开为:
//
// user(input) → assistant(response/tool_calls) → tool(result...)
//
// tool_call 与 tool_result 按 CallID 配对;InactivePages 只隐藏完整的
// tool page,不会留下半个 assistant/tool 对,避免上游 400。
func (a *Agent) renderL1Messages(maxTokens int) []agentAPI.Message {
if a == nil || a.context == nil {
return nil
}
a.context.mu.Lock()
events := make([]*ContextEvent, len(a.context.events))
copy(events, a.context.events)
a.context.mu.Unlock()
var out []agentAPI.Message
used := 0
for _, e := range events {
if e == nil {
continue
}
input := timelineUserText(e)
if input == "" && e.Input == "" && len(e.ToolCalls) == 0 && e.Response == "" {
continue
}
calls := make([]agentAPI.ToolCall, 0, len(e.ToolCalls))
activeCallIDs := make(map[string]bool)
for i, tc := range e.ToolCalls {
page := i + 2
if containsInt(e.InactivePages, page) {
continue
}
calls = append(calls, agentAPI.ToolCall{ID: tc.CallID, Name: tc.Name, Arguments: tc.Arguments, RawArguments: tc.RawArguments})
if tc.CallID != "" {
activeCallIDs[tc.CallID] = true
}
}
msgs := make([]agentAPI.Message, 0, 2+len(calls))
if input != "" {
msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
}
if e.Response != "" || len(calls) > 0 {
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: e.Response, ToolCalls: calls})
}
for _, tr := range e.ToolResults {
if tr.CallID != "" && !activeCallIDs[tr.CallID] {
continue
}
if st, ok := a.toolResultStoreFor(e, tr.Name); ok {
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tr.CallID,
Content: fmt.Sprintf("[stored %d bytes / %d lines — read with toolresult_read(event_id=%q)]", st.TotalBytes, st.TotalLines, e.ID)})
} else {
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tr.CallID, Content: tr.Output})
}
}
for _, m := range msgs {
cost := EstimateTokens(m.Content)
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
cost += EstimateTokens(tc.Name) + EstimateTokens(tc.RawArguments)
}
}
if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
return out
}
used += cost
out = append(out, m)
}
}
return out
}
func (a *Agent) toolResultStoreFor(e *ContextEvent, tool string) (ToolResultStore, bool) {
if e == nil {
return ToolResultStore{}, false
}
for _, st := range e.ToolResultStores {
if st.Tool == tool {
return st, true
}
}
return ToolResultStore{}, false
}
// headRunes 取前 n 个 rune 用于日志(避免把整段回执打进 journal)。
func headRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))