Skip to content

🐛 fix: 根治 Write 历史折叠诱导的模型输出退化(执行记录架构) - #229

Open
yyz159756 wants to merge 7 commits into
itmisx:mainfrom
yyz159756:fix/write-elision
Open

🐛 fix: 根治 Write 历史折叠诱导的模型输出退化(执行记录架构)#229
yyz159756 wants to merge 7 commits into
itmisx:mainfrom
yyz159756:fix/write-elision

Conversation

@yyz159756

Copy link
Copy Markdown

PR 改动说明:根治 Write 历史折叠诱导的模型输出退化

目标分支:main
提交:fc1bacb(单提交,+284/-107,6 文件)
关键词:模型退化 · 工具历史形态 · 执行记录 · prefix cache

1. 背景与动机

Write 工具写入大文件(content > 512 字节)后,模型在后续轮次出现输出退化:

  • 反复 Read 验证刚写入的文件(浪费上下文);
  • 把占位文本/折叠标记当真内容重写,文件被污染;
  • 把系统折叠标记字段当成 Write 的合法参数发出,导致连续失败、任务卡死。

退化是自我强化的:每次系统改写历史参数形态,模型都会把改写后的形态
当成 Write 的标准写法学走,越修越糟。本 PR 从架构层面根治。

2. 问题根因(三层认知)

本质 表现
1 折叠措辞歧义 折叠文本以"已写入…"开头、形同工具结果,模型误读为文件内容
2 占位标记被模仿 换成极简标记后,多轮 Write 时模型仍模仿标记当 content(模式复制)
3 折叠形态被结构级模仿 无论缺 content、带 content_omitted、还是只有 path,模型都会把它当成 Write 的标准写法发出

核心洞察:任何"系统改写后出现在历史里的参数形态"都会被模型学成调用范式。
根本解法不是"选择更好的折叠形态",而是不呈现伪 tool call

3. 架构实现:执行记录(核心)

模型看到的是一段稳定的执行记录,而不是被压缩的伪 tool call:

历史中的 Write(大 content):
  ❌ 旧:assistant tool_calls 参数被折叠改写(缺 content / 带标记)→ 模型模仿
  ✅ 新:从 assistant tool_calls 移除,渲染独立消息:

  [Write 执行记录]
  工具: Write
  路径: config.yaml
  状态: 成功
  大小: 1247 字节
  行数: 42

效果:

  • 历史里 Write 调用只有两种形态:完整 {path, content}(小内容)或执行记录(大内容外置)
  • 模型学到的 Write 范式始终完整,不存在可模仿的伪形态
  • 执行记录是固定模板(仅 路径/大小/行数 变化),prefix cache 友好
  • 执行记录仅写入成功时渲染;失败走普通 tool 消息、错误透传

4. 代码改动清单(6 文件,+284/-107)

文件 改动
agent/llm.go 核心:assistant 过滤大 content Write(elidedWriteInfo 判定)+ 执行记录消息(execRecordMessage);rewriteToolCallArgsForHistory 只修 JSON、不再折叠参数,告别伪 tool call
tools/write_file.go 兜底校验(空内容拒绝+python 引导、短占位符拒绝,错误不回显占位符文本);成功结果含确定性元信息(字节/行/sha256,由 Runtime 计算)。注:sha256 在 Write 工具结果中(非折叠调用可见);折叠 Write 由执行记录呈现,含大小/行数、无 sha256,确定性已足够
tools/tools.go Write 描述加 guidance:正确/错误写法对比、历史为执行记录形式、成功后无需 Read 验证、建空文件用 python
agent/history_args_test.go elidedWriteInfo 判定、执行记录固定模板、rewrite 仅修 JSON、多轮大 Write 全部外置
agent/args_repair_test.go 更新:rewrite 不再折叠(仅修 JSON)
tools/write_file_test.go validator 用例:占位符拒绝、空内容、python 引导、真实放行、长内容不误伤、结果含确定性信息

5. 验证

  • go build ./...go test ./agent/go test ./tools/ 全绿;
  • 实测(两批 20 文件):20/20 写入成功、0 模仿、0 多余 Read、历史仅两种形态;
  • sha256 由 Runtime 对落盘内容计算,模型只读不可伪造。

6. 审阅要点

  • 执行记录仅写入成功时渲染(result.Success);失败走普通 tool 消息、错误原样透传,不会出现"状态: 成功"掩盖失败;
  • rewriteToolCallArgsForHistory 返回值拷贝(内部 make),histToolCalls[:0:0] 零容量切片确保过滤时 append 不改原数组——执行循环仍用原始 toolCalls(含完整 content);
  • elidedIDs[tc.ID]:assistant 过滤与执行循环用同一 tool_call ID 关联,一致;
  • elidedWriteInfo 对小 content / 解析失败返回 ok=false,按普通 tool_call 入历史,不误判;
  • 执行记录为 role=user 系统注入的固定模板(仅 路径/大小/行数 变化),不引入随机 ID / 时间戳 / 动态文本,prefix cache 友好;
  • 错误提示只描述性质、不回显占位符字符串(避免污染模型上下文)。

7. 已知遗留:Agent Loop Completion Gate 缺陷(独立问题,不在本 PR)

reasoning 模型长任务中,thinking 起草代码后可能"声明未执行"——只输出
"将写 XX"等承诺性措辞而无工具调用,任务未做即结束。

机制定位(agent/llm.go completionGate):

  • gate 在"本轮无工具调用"时判断任务是否真完成,只依赖两个信号:
    ① 输出被截断(truncated);② 存在未完成 todo(countPendingTodos);
  • 两信号皆无 → 返回空 → 误判完成、放行结束;
  • 模型"声明将执行但不建 todo"时,信号②天然为空,gap 仅剩信号①;
  • 已有 maxGateNudges 死循环保护(连续催 N 次无进展放行),新检测应复用该机制。

修复方向(建议后续单独处理):

  • gate 增加"承诺性措辞检测":末轮无 tool_calls 且文本含面向未来动作的承诺
    ("将写 / 先写 / 开始执行 / 接下来创建…")→ 视为未完成,注入提示催继续;
  • 需防误报:排除完成性收尾(如"接下来我将总结结果"),只匹配执行承诺。

与 Write 历史形态无关,不阻塞本 PR。

@itmisx

itmisx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

@yyz159756 非常感谢您的pr。这个问题,是因为解决有人在小模型上遇到上下文窗口撑破而引入的,后面优化了压缩逻辑解决掉了,所以鉴于当前的情况,我更愿意恢复到一开始的方案,就是保留write的写入历史,而不是占位提示,你觉得呢。

@yyz159756

yyz159756 commented Aug 5, 2026

Copy link
Copy Markdown
Author

@itmisx 感谢回复,这个方向我认同一半:历史里不该出现占位/折叠形态的 Write——这也是我这个 PR 的核心立场("不呈现伪 tool call",占位文本无论怎么措辞都会被模型模仿)。但如果"恢复为完整保留写入历史",我担心会重新打开当初引入折叠的那个问题,说下理由:

  1. 压缩逻辑优化的覆盖范围和这个问题不是同一个维度。reclaim 只回收  role=tool 的工具输出,够不着 assistant tool_call 里的 content 参数;compact 又按设计保护最近 2 轮不压。而单次大写入(比如 100KB ≈ 2.5 万+ token)刚发生时就在最近 2 轮内——小模型(32K 窗口)单这一条就可能顶爆,压缩根本等不到触发。如果当初引入折叠就是为了这个场景,后续的压缩优化(输出回收、动态阈值、compact fallback)解决的其实是工具输出 bloat 和累积旧轮 bloat,并没有覆盖"单次大写入参数"这一条。

  2. 执行记录不是占位提示。占位提示的问题是"系统改写历史、伪造了一个模型没见过的新形态"; 执行记录是 content 完整落盘、历史里只留固定模板的确定性记录(路径/大小/行数),模型学到的 Write 范式始终是完整的  {path, content} ,不存在可模仿的伪形态。实测 20/20 写入成功、0 次占位模仿、0 次多余 Read——模型反而因为结果里有字节数/行数而更信任写入,不需要 Read 验证。

  3. 其实分歧面很小:小文件(≤512B)两边都是原样保留进历史,只有大文件这一种情况不同。如果担心模型看不到自己刚写的内容,可以把内联阈值从 512B 提到几 KB——大多数真实写入仍完整可见,只有真正的大文件走执行记录。这样既没有占位符、也不撑爆小模型上下文,是否可行?

@itmisx

itmisx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

@yyz159756 你上一条的第 1 点说服我了 —— reclaim 只回收 role=tool 的输出、够不着 tool_call 里的 content 参数,compact 又按设计保护最近 2 轮,这两条我核过,机制描述准确。单次大写入刚发生时确实处在压缩够不着的位置。数字上补充一下实测(tiktoken 真实分词):100KB 中文 ≈ 23,628 token、100KB 代码 ≈ 12,800 token;而 32K 窗口的压缩触发线是 13,108 —— 中文场景下单条就越线,你的担心成立。

所以我把实现仔细过了一遍。下面六个问题,按严重程度排,前三个我写了复现。方向上走哪条路我还在权衡,但这几点无论如何都需要处理。


🔴 1. 执行记录插在 tool 组中间,同批次其它工具结果会被丢弃

agent/sanitize.godefault 分支(user/system)会执行 valid = make(map[string]bool) —— user 消息会终结 tool 配对组。而执行记录正好插在被移除调用的位置,也就是组中间。

一轮里同时有大 Write 和 Read 时:

发送前:
  [1] assistant tool_calls=[call_read]
  [2] user      "[Write 执行记录]…"      ← 插在中间
  [3] tool      tool_call_id=call_read

sanitizeToolPairs 之后:
  [1] assistant tool_calls=[call_read]
  [2] user      "[Write 执行记录]…"
  ❌ 丢弃 1 条 tool / 悬挂 [call_read]

Read 的结果静默丢失,同时留下悬挂 tool_call —— 严格后端会 400,正是 sanitizeToolPairs 存在的目的(issue #94)。

好消息:这是位置问题,不是架构问题。 我实测把执行记录追加到整批工具循环之后就完好了:

【B】执行记录追加在整批之后
  大Write + Read           ✅ 配对完好  (4→4 条)
  两个大Write + 两个Read     ✅ 配对完好  (6→6 条)
【C】整轮只有大 Write
  assistant 无 tool_calls   ✅ 配对完好

🔴 2. 大 Write 失败时,错误信息被吞掉

你在循环前(llm.go:846)就把 tool_call 从 assistant 过滤掉了,所以失败分支那条 role=tool 消息是孤儿,会被消毒器丢弃:

失败的大Write → 错误信息传达给模型: false

模型看到的是:自己啥也没调用、啥结果也没有 —— 不知道写失败了,大概率当成功继续往下走。

PR 描述第 6 节强调「失败走普通 tool 消息、错误原样透传,不会出现"状态: 成功"掩盖失败」,但实际做不到。这条挪位置也修不好,得改成:assistant 先带完整 tool_calls 入 convo,跑完工具循环后再回头把成功的大 Write 从中摘掉 —— 失败的保留 tool_call,错误就能正常配对。

🔴 3. elidedIDs 和执行记录用了两份不同的 args

  • llm.go:846histToolCalls(已经 repairArgsJSON 修过)构建 elidedIDs
  • 执行循环 llm.go:891 遍历的是 toolCalls(原始 args),又调一次 elidedWriteInfo

模型吐出截断 arguments 时(issue #201 的典型场景)两者判定不一致:

用已修复的 args 判定(决定是否 elide): ok=true
用原始 args 判定(决定记录内容):     ok=false → path="" size=0 lines=0
→ "[Write 执行记录] 工具: Write 路径:  状态: 成功 大小: 0 字节 行数: 0"

写出空路径、0 字节的"成功"记录。建议把 elidedWriteInfo 的结果直接缓存进 elidedIDs 的 value,只算一次。

🟡 4. role=user 带来三个副作用

执行记录经 HistoryUpdateMsg{History: convo}(llm.go:1169)流回 m.history 并持久化,于是:

  • tui/model.go:3439 rebuildChatFromHistoryrole=user 渲染成用户气泡 → 重启 / 切会话后,聊天区会冒出用户从没说过的「[Write 执行记录]」
  • agent/compact.go:80 isTurnBoundary 把 user 算作轮边界 → 每个大 Write 虚增一轮,影响压缩的轮数判断和切点
  • history.gob 持久化,/undo 也会碰到

需要换一个不污染这几条路径的载体,或者在渲染 / 轮数统计处显式过滤。

🟡 5. 占位符检测挡不住它最想挡的那个模式

const minPlaceholderCheckBytes = 32
if len(content) < minPlaceholderCheckBytes {   // 只在内容「短于 32 字节」时才检测
    // 匹配 placeholderPatterns
}

placeholderPatterns 里最主要的模式就是 "[已写入",而旧折叠文本实测远超 32 字节:

'[已写入 config.yaml,1247 字节/42 行;需要内容用 Read 查看]'   70 字节  检测生效: False
'[已写入 a.go,100 字节/5 行;需要内容用 Read 查看]'             61 字节  检测生效: False
'<elided>'                                                8 字节  检测生效: True
'内容已省略'                                                15 字节  检测生效: True

模型真去模仿折叠标记时,内容必然超过 32 字节,检测直接被跳过。 长度门槛的方向反了 —— 它挡住的恰好是最该拦的那类。建议去掉长度门槛,改成"命中模式即拒"(误伤风险靠模式表本身的精确度控制,<elided> / [已写入 这类不会出现在真实代码里)。

🟡 6. 拒绝空内容是功能回退

if strings.TrimSpace(content) == "" {
    return fmt.Errorf("...若要创建空文件,请用 python -c \"open('目标路径','w').close()\"...")
}

建空文件是合法需求(__init__.py.gitkeep)。改用 python 需要 Bash 可用,而 plan 模式的只读工具集里没有 Bash,模型就彻底建不了空文件。而且 TrimSpace 连"只有一个换行"的文件也会拒。

用一个确定的功能回退换一个启发式防护不太划算,建议空内容放行,只保留占位符模式检测。

@itmisx

itmisx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

@yyz159756 所以我想恢复到之前的原样显示,只不过加一个兜底,如果写入超过一定阈值,可以提示模型分多次写入。我想越简单的逻辑越稳定。我也不确定引入你的方案后,会不会在不同模型面前产生其它副作用。

@yyz159756

yyz159756 commented Aug 5, 2026

Copy link
Copy Markdown
Author

@itmisx 感谢详细 Review。

重新评估后,我同意当前 #229 方案的复杂度偏高。

原方案通过修改历史展示形式(write-elision)降低上下文占用,但实际引入了一系列新的隐含风险:

  • tool_call / tool_result 配对维护复杂度增加;
  • execution record 引入后污染 history 语义,需要额外兼容 UI、compact、undo 等路径;
  • 不同模型对折叠后的历史结构可能产生不同理解,存在结构模仿和行为退化风险;
  • 需要维护额外状态与过滤逻辑,长期维护成本较高。

这些问题说明:修改 Agent 内部 history 语义的收益与风险不太匹配。

因此倾向于采用更简单、可预测的方案:

  1. 恢复 Write content 的原始显示逻辑;
  2. 不改变历史消息结构;
  3. 对超大 Write 增加工具层限制,引导模型拆分写入。

初步设计:

const maxInlineWriteContentBytes = 512

该阈值建议不要硬编码,后续作为配置项暴露。

当单次 Write content 超过阈值时:

  • 不进行 history elision;

  • Write 工具返回明确错误提示,例如:

    content size exceeds maximum inline write limit, please split into smaller Write operations.

这样可以保证:

  • tool schema 保持稳定;
  • history / compact / prefix cache 行为不变;
  • 不依赖模型理解特殊折叠格式;
  • 不引入额外的 message 类型和状态同步逻辑。

目前 write-elision 方案中的问题分析和测试结果会保留作为后续优化参考,但当前 PR 不继续沿该方向扩展。

感谢指出这个设计方向上的问题。

@itmisx

itmisx commented Aug 5, 2026

Copy link
Copy Markdown
Owner

@yyz159756 很感谢您反馈问题,并积极参与修复,但我还是担心会影响到稳定性。 我提交了一个优化修复,恢复到之前的原样显示,写入时设置阈值(按窗口大小自适应) #231

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants