mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
问题(核实过):工具结果进 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 轮并发判据全绿。
153 lines
5.5 KiB
Go
153 lines
5.5 KiB
Go
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, ", "))
|
||
}
|