Files
HomeAgent/internal/agent/core/toolresult_budget.go
JianFeeeee 8ca28eb071 feat(toolcall): 工具结果只统计不裁剪(方案 B),并治掉 seq 侧的静默截断
问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接
`Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。
时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管
a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破
预算而内核不会提前发现。

为什么**不裁剪**(与方案 A 的取舍):
· 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定;
· 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复
  吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%);
· 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询),
  而不是内核单方面替模型决定。

改动:
· core/toolresult_budget.go: checkToolResultSize 只**计数+报告**;
  阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉);
  报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发
  消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。
· 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的
  内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。
· TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询
  "是否有工具在稳定产出超大结果"。

★ 顺带治掉 seq 侧一处**我自己留下的静默截断**:
handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字
且**无任何标注** —— 正是我批评过的静默降级。
改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前
160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。
端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷
("变量槽被截到 160/5000 字却没有任何标注")。

判据(toolresult_budget_test.go,4 条):
· 400KB 结果触发超限报告(含工具名与 token 数)
· ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式)
· 小结果不误报(噪音会淹没有效信号)
· 报告文案可执行:带工具名、token 数、改法建议

变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。

另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**——
循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map),
`-race` 下 20 轮并发判据全绿。
2026-09-27 14:05:38 +08:00

153 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"log"
"strings"
)
// 本文件实现「工具结果**只统计不裁剪**」(方案 B)。
//
// 现状与风险(已核实):工具结果进 f.Msgs 时**没有任何长度上限**
// (task.go 直接 `Content: result`),内核也**不预检**是否超长 ——
// 超限由上游 API 报错。时间线那一侧有预算(ContextTokens = 0.8×窗口,
// 进消息前就裁过),但那只管 a.context 的历史事件,**不管单条工具结果**。
// ⇒ 一条巨大工具结果可能直接冲破预算,而内核不会提前发现。
//
// 为什么**不裁剪**(与方案 A 的取舍):
// · 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定;
// · 模型无法得知"这里被截断了",会基于残缺数据下结论
// —— 这与本仓反复吃亏的「静默降级」是同一族问题;
// · 处置权应交给调度器/上层(可以告警、可以拒绝、可以让模型自己决定
// 换更小的查询),而不是内核单方面替模型决定。
//
// ⇒ 本文件只做两件事:**计数**与**报告**。
// 默认阈值:单条工具结果超过 1/8 目标预算即报告。
//
// 取 1/8 而非"整个预算":一条结果吃掉全部预算时,上下文里其它内容
// (记忆召回、时间线、对话历史)就全被挤掉了 —— 那才是真正需要预警的
// 场景。具体数值可由配置覆盖(见 SetToolResultWarnTokens)。
const defaultToolResultWarnDivisor = 8
// toolResultReporter 报告一次「工具结果过大」。
// 抽成接口是为了让判据能观测报告内容,而不必去抓日志。
type toolResultReporter interface {
report(tool string, tokens, budget int, msg string)
}
// logReporter 是默认实现:写日志。
//
// 为什么默认只记日志、不给模型发消息:给模型发"你刚才的输出太大了"
// 是在**已经花掉的 token 之上**再加一条 system 消息,且它对当前这轮
// 决策毫无帮助。真正需要处置的是**下一轮**的调度(是否还塞更多上下文)。
// 交由上层订阅日志或替换 reporter 决定。
type logReporter struct{}
func (logReporter) report(tool string, tokens, budget int, msg string) {
log.Printf("[agent] tool %s result is large: %d tokens (%.0f%% of budget %d) — %s",
tool, tokens, percentOf(tokens, budget), budget, msg)
}
func percentOf(v, total int) float64 {
if total <= 0 {
return 0
}
return float64(v) * 100 / float64(total)
}
// checkToolResultSize 统计单条工具结果的大小,必要时报告。
//
// ⚠️ **只统计,不裁剪**(见文件头)。返回 true 表示「已报告过」。
func (a *Agent) checkToolResultSize(tool, result string) bool {
if a == nil || result == "" {
return false
}
tokens := EstimateTokens(result)
limit := a.toolResultWarnLimit()
if tokens <= limit {
return false // 正常,不打扰
}
// ⚠️ 文案**必须带工具名**:报告是给日志/状态面看的,不带名字就无法
// 判断是哪个工具在稳定产出超大结果(判据 TestReportIsActionable 钉住)。
msg := fmt.Sprintf("工具 %s 的结果约 %d token,超出单条上限 %d(占预算 %.0f%%)。"+
"建议:收窄查询条件、分页取、或把大结果转存后只取摘要。**结果未被裁剪**,模型看到的是完整内容。",
tool, tokens, limit, percentOf(tokens, limit))
r := a.toolResultReporter
if r == nil {
r = logReporter{}
}
r.report(tool, tokens, limit, msg)
return true
}
// toolResultWarnLimit 返回本 agent 的单条工具结果告警阈值。
func (a *Agent) toolResultWarnLimit() int {
if a.toolResultWarnTokens > 0 {
return a.toolResultWarnTokens
}
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
base := budget.ContextTokens
if base <= 0 {
base = budget.TargetUsage
}
if base <= 0 {
base = 8192
}
return base / defaultToolResultWarnDivisor
}
// SetToolResultWarnTokens 覆盖告警阈值(0 = 用默认值)。
//
// 供部署方按模型窗口调整:窗口大的源(1M)用默认阈值,窗口小的源
// (32K)可能需要更小的单条上限。
func (a *Agent) SetToolResultWarnTokens(n int) { a.toolResultWarnTokens = n }
// OversizeToolReports 返回本任务中「过大工具结果」的累计计数。
//
// 供调度器/状态面查询:连续出现说明某个工具在稳定地产出超大结果,
// 值得换用更窄的查询方式。
func (f *TaskFrame) OversizeToolReports() int {
if f == nil {
return 0
}
return f.oversizeTools
}
// noteOversizeTool 记一次过大结果。
func (f *TaskFrame) noteOversizeTool(name string) {
if f == nil {
return
}
f.oversizeTools++
if f.oversizeToolNames == nil {
f.oversizeToolNames = make([]string, 0, 4)
}
for _, n := range f.oversizeToolNames {
if n == name {
return
}
}
f.oversizeToolNames = append(f.oversizeToolNames, name)
}
// OversizeToolNames 返回出现过超大结果的工具名(去重、保序)。
func (f *TaskFrame) OversizeToolNames() []string {
if f == nil || len(f.oversizeToolNames) == 0 {
return nil
}
out := make([]string, len(f.oversizeToolNames))
copy(out, f.oversizeToolNames)
return out
}
// oversizeHintFor 供状态面渲染用的一行摘要。
func (f *TaskFrame) oversizeHintFor() string {
if f == nil || f.oversizeTools == 0 {
return ""
}
names := f.OversizeToolNames()
return fmt.Sprintf("本任务有 %d 次超大工具结果(%s)——未被裁剪,但已占用大量上下文预算",
f.oversizeTools, strings.Join(names, ", "))
}