feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。

- 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。

- usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。

- 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。

- 顺手修越界:config.go 505 行(超 500)拆出 llm.go。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
This commit is contained in:
AskaEth committed 2026-10-04 17:57:08 +08:00
1 parent 5b2b385524
commit 72b95df22b
39 files changed
+2496 -377

No files matched your search

+48 -21
View File
@@ -68,34 +68,38 @@ type Compactor interface {
Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error)
}
// defaultCompactor 是内置的结构化 compactor。它无状态,
// 且并发安全。
type defaultCompactor struct{}
// defaultCompactor 是内置的结构化 compactor。它无状态(documentInjectChars 只在
// 构造时给定),且并发安全。
type defaultCompactor struct {
// documentInjectChars 是单份附件注入上下文的字符预算,用于估算文档部件的
// token 代价(documentPartTokens)。0 表示用 defaultDocumentInjectChars。
documentInjectChars int
}
// Compact 实现 Compactor。
func (defaultCompactor) Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
func (c defaultCompactor) Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("loop: compact: %w", err)
}
return compactMessages(ctx, messages, budget)
return compactMessages(ctx, messages, budget, c.documentInjectChars)
}
// compactMessages 是共享的压缩实现。它绝不改写
// 输入切片;压缩结果始终是新分配的。
func compactMessages(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
func compactMessages(ctx context.Context, messages []model.Message, budget, docInjectChars int) ([]model.Message, error) {
if budget <= 0 || len(messages) < minCompactMessages {
return messages, nil
}
if estimateContextTokens(messages) <= budget {
if estimateContextTokensWith(messages, docInjectChars) <= budget {
return messages, nil
}
prefixEnd := summaryPrefixEnd(messages)
keepTokens := max(int(float64(budget)*keepRecentFraction), minKeepTokens)
keepStart := findKeepStart(messages, prefixEnd, keepTokens)
keepStart := findKeepStart(messages, prefixEnd, keepTokens, docInjectChars)
if keepStart <= prefixEnd {
// 全部内容都被保留;没有可安全摘要的前缀。
return messages, nil
@@ -144,7 +148,7 @@ func summaryPrefixEnd(messages []model.Message) int {
// 给 turn 计分,因此最近的 turn 得以存活,尾部的工具结果
// 绝不被切开。不存在用户 turn 时(少见),它不额外保留任何东西
// 并从 from 返回,因此什么也不被摘要。
func findKeepStart(messages []model.Message, from, keepTokens int) int {
func findKeepStart(messages []model.Message, from, keepTokens, docInjectChars int) int {
n := len(messages)
turns := make([]int, 0, 8)
for i := from; i < n; i++ {
@@ -163,8 +167,9 @@ func findKeepStart(messages []model.Message, from, keepTokens int) int {
if i+1 < len(turns) {
to = turns[i+1]
}
if i == len(turns)-1 || acc+estimateContextTokens(messages[turns[i]:to]) <= keepTokens {
acc += estimateContextTokens(messages[turns[i]:to])
size := estimateContextTokensWith(messages[turns[i]:to], docInjectChars)
if i == len(turns)-1 || acc+size <= keepTokens {
acc += size
keptStart = turns[i]
} else {
break
@@ -173,11 +178,19 @@ func findKeepStart(messages []model.Message, from, keepTokens int) int {
return keptStart
}
// estimateContextTokens 返回 messages 的保守 token 估算。它是
// 启发式(字符 ÷ charsPerToken,加上每条消息、每个图像与每个文档的开销),
// 因此无依赖且离线,但单调性足够驱动
// 压缩决策。
// estimateContextTokens 返回 messages 的保守 token 估算,文档部件按**缺省**注入
// 上限计入(defaultDocumentInjectChars)。需要按本回合真实注入预算估算的调用方
// (compactor)请用 estimateContextTokensWith。
func estimateContextTokens(messages []model.Message) int {
return estimateContextTokensWith(messages, defaultDocumentInjectChars)
}
// estimateContextTokensWith 返回 messages 的保守 token 估算。它是
// 启发式(字符 ÷ charsPerToken,加上每条消息、每个图像与每个文档的开销),
// 因此无依赖且离线,但单调性足够驱动压缩决策。
// docInjectChars 是单份文档注入上下文的字符上限,决定文档部件在估算里最多
// 算多少(0 = 缺省值)。
func estimateContextTokensWith(messages []model.Message, docInjectChars int) int {
total := 0
for _, m := range messages {
total += messageOverheadTokens
@@ -188,7 +201,7 @@ func estimateContextTokens(messages []model.Message) int {
case model.ContentTypeImage:
total += imageTokens
case model.ContentTypeDocument:
total += documentPartTokens(part)
total += documentPartTokens(part, docInjectChars)
}
}
}
@@ -203,10 +216,13 @@ func estimateContextTokens(messages []model.Message) int {
// 被估成 0 token,在压缩里等同于空内容(design/24 §9.5 移交的静默吞掉)。
// 大文件(尤其在预算处截断注入、或压根读不到)会被这个估计偏高地计入,方向
// 是「少放内容进上下文」——保守收紧,而不是溢出模型窗口。
func documentPartTokens(part model.Content) int {
func documentPartTokens(part model.Content, docInjectChars int) int {
if docInjectChars <= 0 {
docInjectChars = defaultDocumentInjectChars
}
chars := part.DocumentSize
if chars > documentInjectedCharsCap {
chars = documentInjectedCharsCap
if chars > int64(docInjectChars) {
chars = int64(docInjectChars)
}
if chars < 0 {
chars = 0
@@ -300,9 +316,20 @@ func capSummary(s string, max int) string {
// truncateRunes 把 s 缩短到至多 n 个 rune,Unicode 安全。
func truncateRunes(s string, n int) string {
out, _ := truncateRunesAt(s, n)
return out
}
// truncateRunesAt 与 truncateRunes 同义,另外报告是否发生了截断。
// 「是否截断」是清单行里续读游标的唯一判据(截断才给游标),因此它必须来自
// 截断动作本身,而不是「长度是否等于上限」这类再推断。
func truncateRunesAt(s string, n int) (string, bool) {
if n <= 0 {
return "", s != ""
}
runes := []rune(s)
if len(runes) <= n {
return s
return s, false
}
return string(runes[:n])
return string(runes[:n]), true
}
@@ -0,0 +1,121 @@
package loop
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestReportContextPressureWarnsAtNinetyPercent 钉住输入逼近预算的 WARN
// (design/04 附录 A.3 第 5 条):达到预算 90% 记一条 WARN、低于不记,且**不阻断**
// 任何流程(它只是日志)。
func TestReportContextPressureWarnsAtNinetyPercent(t *testing.T) {
t.Parallel()
const budget = 1000
cases := []struct {
name string
promptTokens int
wantWarn bool
}{
{"恰好 90%", 900, true},
{"超过 90%", 990, true},
{"低于 90%", 899, false},
{"提供商未上报用量", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
log, buf := captureLoopLog()
l := New(Options{MaxContextTokens: budget, Log: log})
l.reportContextPressure(c.promptTokens)
got := strings.Contains(buf.String(), "near the context budget")
if got != c.wantWarn {
t.Fatalf("promptTokens=%d budget=%d: WARN=%v, want %v(日志:%q)",
c.promptTokens, budget, got, c.wantWarn, buf.String())
}
if !c.wantWarn {
return
}
records := loopJSONRecords(t, buf)
if len(records) != 1 || records[0]["prompt_tokens"] != float64(c.promptTokens) {
t.Fatalf("告警记录 = %+v, want 恰好一条且带上 prompt_tokens=%d", records, c.promptTokens)
}
})
}
}
// TestReportContextPressureSilentWhenCompactionOff 钉住「压缩关闭时不吵」:
// 关闭压缩(预算 0/负)的部署没有预算可言,绝不能因为预算为 0 而把每条调用都
// 判成「超 90%」。
func TestReportContextPressureSilentWhenCompactionOff(t *testing.T) {
t.Parallel()
for _, budget := range []int{0, -1} {
log, buf := captureLoopLog()
l := New(Options{MaxContextTokens: budget, Log: log})
l.reportContextPressure(1 << 20)
if strings.Contains(buf.String(), "near the context budget") {
t.Fatalf("预算 %d 时不该记 WARN:%q", budget, buf.String())
}
}
}
// TestStreamStepCarriesPromptTokensForPressure 钉住接线:流式主路径把提供商上报的
// 输入 token 带进 stepOutcome,逼近预算的判定因此真的会在这条路径上发生 ——
// 而不是只在 reportContextPressure 这个函数里「理论上可行」。
func TestStreamStepCarriesPromptTokensForPressure(t *testing.T) {
t.Parallel()
log, buf := captureLoopLog()
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "好", PromptTokens: 950}}}}
l := New(Options{Agent: newTestAgent(t, ad, nil), MaxContextTokens: 1000, Log: log,
OnStream: func(model.StreamEvent) {}})
if _, err := l.Run(context.Background(), "sess-pressure", []model.Message{model.TextMessage(model.RoleUser, "在吗")}); err != nil {
t.Fatalf("Run: %v", err)
}
if !strings.Contains(buf.String(), "near the context budget") {
t.Fatalf("流式路径没有触发预算告警:%q", buf.String())
}
records := loopJSONRecords(t, buf)
found := false
for _, rec := range records {
if rec["msg"] == "model input is near the context budget" {
found = true
if rec["prompt_tokens"] != float64(950) || rec["budget"] != float64(1000) {
t.Fatalf("告警字段 = %+v, want prompt_tokens=950 / budget=1000", rec)
}
}
}
if !found {
t.Fatalf("日志里没有预算告警记录:%q", buf.String())
}
}
// captureLoopLog 返回输出 JSON 记录的 logger 与缓冲:断言结构化字段(而不是文本
// 子串)时用它。nil logger 在循环里会被换成丢弃 logger,所以测试必须显式注入。
func captureLoopLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
// loopJSONRecords 把缓冲里的 JSON 记录逐条解出来,便于按字段断言。
func loopJSONRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
rec := map[string]any{}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("日志行不是合法 JSON: %v(%q)", err, line)
}
out = append(out, rec)
}
return out
}
+58 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -18,16 +19,24 @@ import (
// 2. **清单恒在**:每份文档至少要留下「名、MIME、大小、结论」这一行,
// 绝不出现「传上去了但模型一无所知」的空上下文(§4.2-3);
// 3. **失败必须可见**:抽取失败解析为诚实的文本标记 + status=unreadable,
// 回合照常继续(§4.4)。
// 回合照常继续(§4.4);
// 4. **注入预算在这里生效**(切片 T1 §7.2):正文按本回合的注入预算
// (l.documentInjectChars)截取,ref.Chars = 实际注入的字符数、
// ref.Truncated = 注入是否被截断。sidecar 存的是抽取全文,因此清单行给出的
// 续读游标从此**真的是**「继续读的起点」,与 document-read 的 offset 对齐。
//
// 它作用于**装配后的模型可见视图**而不是消息窗口:窗口(checkpoint 快照、
// 记忆提取输入)里只留文档引用(§2.4),正文每次装配时按引用读 sidecar 得到。
const (
// documentInjectedCharsCap 是单份文档注入上下文的字符上限(design/24 §3.4
// 的 64K 字符)。它与抽取器的 documents.MaxExtractChars **同值同义**:这里
// 不重复实现截断(截断由抽取器完成并写进 sidecar,供后续回合复用),只在
// 压缩估算里按同一口径计入文档部件的代价。两个包各自独立,避免反向依赖。
documentInjectedCharsCap = 64 * 1024
// defaultDocumentInjectChars 是单份文档注入上下文的**字符**上限的缺省值
// (64K 字符)。它的实际取值由组合根按推导出的上下文预算给出
// (Options.DocumentMaxInjectChars / DocumentInjectCharsFor):64K 是
// 「小窗口下不退化」的下限,不再兼任抽取期的截断预算 —— 抽取期只受
// documents.MaxSidecarBytes 这个安全上限约束(切片 T1 §7.2)。
//
// 它同时是压缩估算里文档部件代价的封顶依据(compactor.go 的
// documentPartTokens):注入多少,就按多少计入 token。
defaultDocumentInjectChars = 64 * 1024
// documentMinTokens 是清单行(名/MIME/大小/结论)的粗略 token 下限:无论
// 文档多小、甚至完全读不到,清单行都进了上下文,因此它不是免费的。
@@ -40,8 +49,35 @@ const (
// bytesPerKiB / bytesPerMiB 是清单行渲染大小用的单位(仅展示用)。
bytesPerKiB = 1024
bytesPerMiB = 1024 * bytesPerKiB
// documentInjectBudgetShare 是单份附件注入正文可占推导预算的份数(1/份数):
// 一个回合最多 5 个附件(design/24 §3.4),取 8 意味着「每份最多占预算的
// 1/8」—— 5 份合计不超过 5/8,其余留给系统提示词、历史、工具 schema 与本轮
// 输出。字符与 token 的换算沿用循环的离线估算口径(charsPerToken)。
documentInjectBudgetShare = 8
)
// DocumentInjectCharsFor 由推导出的上下文预算(token)算出**单份附件**注入
// 模型可见上下文的字符预算(design/24 §9.6 连带项;切片 T1 §7.2 第 2 条)。
//
// 这是「注入预算随上下文预算推导」这条要求的落点:1M 窗口 × 2/3 ⇒ 699050 token
// 的预算 ⇒ 单份附件可注入 349525 字符,而不是旧的 64K 卡死。取值下限是历史默认
// 64K(小窗口下附件行为不退化),并且绝不超过预算本身(预算被显式设得很小时,
// 也不该让一份附件就吃掉整个窗口)。
func DocumentInjectCharsFor(budgetTokens int) int {
if budgetTokens <= 0 {
return defaultDocumentInjectChars
}
chars := budgetTokens * charsPerToken / documentInjectBudgetShare
if chars < defaultDocumentInjectChars {
chars = defaultDocumentInjectChars
}
if chars > budgetTokens {
chars = budgetTokens
}
return chars
}
// 清单行与结论文案(design/24 §4.3、§4.4)。清单行恒在,结论文案随结论变化:
// 已抽取(可含截断与续读游标)、仅清单、不可读。
const (
@@ -124,7 +160,7 @@ func (l *Loop) resolveDocumentMessage(ctx context.Context, m model.Message) (mod
content = append(content, part)
continue
}
ref, body := documentResolution(part, refs, bodies, failed)
ref, body := documentResolution(part, refs, bodies, failed, l.documentInjectChars)
content = append(content, model.Content{Type: model.ContentTypeText, Text: documentBlock(part, ref, body)})
out = append(out, ref)
}
@@ -152,7 +188,12 @@ func (l *Loop) documentResolutions(ctx context.Context, m model.Message) (map[st
// 状态口径:解析器给出的三态照用;缺失时 —— 解析器整个缺席(功能未接线)算
// listed(「只给了清单」是事实),而解析器**报了错**(整条消息解析不了)算
// unreadable(作者「读不到正文」比「只给了清单」更准确,也让失败对客户端可见)。
func documentResolution(part model.Content, refs map[string]history.DocumentRef, bodies map[string]string, failed bool) (history.DocumentRef, string) {
//
// 注入预算(injectChars)在这一步生效:已抽取的正文按 rune 截到 injectChars,
// ref.Chars 记**实际注入**的字符数、ref.Truncated 记**注入**是否被截断。抽取侧
// 自己那套 chars/truncated(sidecar 正文长度、是否撞上安全上限)不是客户端关心
// 的东西,读侧另有出口(document-read 的 totalChars / extractTruncated)。
func documentResolution(part model.Content, refs map[string]history.DocumentRef, bodies map[string]string, failed bool, injectChars int) (history.DocumentRef, string) {
ref := history.DocumentRef{Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize}
if got, ok := refs[part.DocumentName]; ok {
ref.Status, ref.Chars, ref.Truncated = got.Status, got.Chars, got.Truncated
@@ -168,7 +209,13 @@ func documentResolution(part model.Content, refs map[string]history.DocumentRef,
if ref.Status == history.DocumentStatusUnreadable && body == "" {
body = documentUnreadableBody
}
return ref, body
if ref.Status != history.DocumentStatusExtracted {
return ref, body
}
injected, truncated := truncateRunesAt(body, injectChars)
ref.Chars = utf8.RuneCountInString(injected)
ref.Truncated = truncated
return ref, injected
}
// documentStatusKnown 报告 status 是不是三个已知取值之一。unknown 一律按
@@ -204,7 +251,8 @@ func documentBlock(part model.Content, ref history.DocumentRef, body string) str
// documentStatusNote 渲染清单行里的结论部分。截断时给出**续读游标**(已注入
// 的字符数即抽取正文里的偏移量),使模型与后续的 document-read 工具知道从哪
// 继续(design/24 §4.3:截断绝不静默)。
// 继续(design/24 §4.3:截断绝不静默)。因为 sidecar 存的是抽取全文
// (切片 T1 §7.2),这个游标**真的**是「继续读的起点」。
func documentStatusNote(ref history.DocumentRef) string {
switch ref.Status {
case history.DocumentStatusExtracted:
+110 -13
View File
@@ -59,11 +59,12 @@ func messageTextOf(m model.Message) string {
}
// TestResolveDocuments_InjectsListAndBody 钉住主路径:清单行恒在,正文紧随其后,
// 文档部件全部被替换掉(同一位置)。
// 文档部件全部被替换掉(同一位置)。Chars 是**实际注入**的字符数(装配期按注入
// 预算算出,切片 T1 §7.2),不是解析器自称的那个数。
func TestResolveDocuments_InjectsListAndBody(t *testing.T) {
t.Parallel()
res := &fakeDocumentResolver{
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 5}},
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 4}},
bodies: map[string]string{docA: "正文内容"},
}
l := New(Options{DocumentResolver: res})
@@ -83,8 +84,8 @@ func TestResolveDocuments_InjectsListAndBody(t *testing.T) {
if !strings.HasPrefix(text, "看看这个") {
t.Fatalf("用户原文顺序被改变: %q", text)
}
if len(refs) != 1 || refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 5 {
t.Fatalf("结论 = %+v, want extracted/chars=5", refs)
if len(refs) != 1 || refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 4 {
t.Fatalf("结论 = %+v, want extracted/chars=4", refs)
}
if refs[0].Name != docA || refs[0].MIME != "text/plain" || refs[0].Size != 2048 {
t.Fatalf("结论的身份字段 = %+v, want 取自部件", refs[0])
@@ -110,21 +111,25 @@ func TestResolveDocuments_KeepsListWithoutResolver(t *testing.T) {
}
// TestResolveDocuments_TruncatedGivesCursor 钉住截断诚实(design/24 §4.3):
// 截断时清单行给出续读游标(已注入字符数),绝不静默截断。
// 注入被截断时清单行给出续读游标(已注入字符数 = 继续读的起点),绝不静默截断。
func TestResolveDocuments_TruncatedGivesCursor(t *testing.T) {
t.Parallel()
res := &fakeDocumentResolver{
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 700, Truncated: true}},
bodies: map[string]string{docA: "前 700 字符"},
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 700}},
bodies: map[string]string{docA: strings.Repeat("正", 700)},
}
l := New(Options{DocumentResolver: res})
l := New(Options{DocumentResolver: res, DocumentMaxInjectChars: 100})
out, _ := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 99)})
text := messageTextOf(out[0])
for _, want := range []string{"已截断", "offset=700"} {
for _, want := range []string{"已截断", "offset=100"} {
if !strings.Contains(text, want) {
t.Fatalf("注入块 %q 缺 %q", text, want)
}
}
_, body, _ := strings.Cut(text, "\n")
if body != strings.Repeat("正", 100) {
t.Fatalf("注入正文不是 100 个字符的 rune 前缀: %q", body)
}
}
// TestResolveDocuments_UnreadableIsHonest 钉住失败可见(design/24 §4.4):
@@ -279,6 +284,89 @@ func TestUserMessageKey_IncludesDocumentRef(t *testing.T) {
}
}
// TestResolveDocuments_AppliesInjectionBudget 钉住切片 T1 §7.2 第 2 条:注入预算在
// **装配期**生效 —— ref.Chars 是实际注入的字符数、ref.Truncated 是注入是否被截断,
// 清单行的续读游标因此是「继续读的起点」(sidecar 存全文,游标之外还有正文)。
func TestResolveDocuments_AppliesInjectionBudget(t *testing.T) {
t.Parallel()
const budget = 10
body := "0123456789ABCDEFGHIJ"
res := &fakeDocumentResolver{
// 解析器给的是抽取侧的事实(sidecar 全文 20 字符、未撞安全上限)。
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: len(body)}},
bodies: map[string]string{docA: body},
}
l := New(Options{DocumentResolver: res, DocumentMaxInjectChars: budget})
out, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 20)})
text := messageTextOf(out[0])
if !strings.Contains(text, "已截断") || !strings.Contains(text, fmt.Sprintf("offset=%d", budget)) {
t.Fatalf("注入块缺截断与续读游标: %q", text)
}
if !strings.Contains(text, body[:budget]) || strings.Contains(text, body[budget:]) {
t.Fatalf("注入正文不是预算内的前缀: %q", text)
}
if len(refs) != 1 || refs[0].Chars != budget || !refs[0].Truncated {
t.Fatalf("结论 = %+v, want chars=%d + truncated(注入侧事实)", refs, budget)
}
// 正文在预算内时:不截断、不给游标,Chars 仍是实际注入的字符数。
small := &fakeDocumentResolver{
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 3}},
bodies: map[string]string{docA: "短正文"},
}
l2 := New(Options{DocumentResolver: small, DocumentMaxInjectChars: budget})
out2, refs2 := l2.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 9)})
if text2 := messageTextOf(out2[0]); strings.Contains(text2, "已截断") {
t.Fatalf("预算内却被标成截断: %q", text2)
}
if len(refs2) != 1 || refs2[0].Chars != 3 || refs2[0].Truncated {
t.Fatalf("结论 = %+v, want chars=3 + 未截断", refs2)
}
}
// TestUnexportedInjectBudgetDefaultsOnConstruction 钉住零值语义:Options 不设
// DocumentMaxInjectChars 时用缺省 64K 字符(小窗口部署行为不退化),而不是 0
// (0 会把正文整个截掉)。
func TestUnexportedInjectBudgetDefaultsOnConstruction(t *testing.T) {
t.Parallel()
if got := New(Options{}).documentInjectChars; got != defaultDocumentInjectChars {
t.Fatalf("缺省注入预算 = %d, want %d", got, defaultDocumentInjectChars)
}
if got := New(Options{DocumentMaxInjectChars: -5}).documentInjectChars; got != defaultDocumentInjectChars {
t.Fatalf("非正注入预算 = %d, want 回落 %d", got, defaultDocumentInjectChars)
}
if got := New(Options{DocumentMaxInjectChars: 1234}).documentInjectChars; got != 1234 {
t.Fatalf("显式注入预算 = %d, want 1234", got)
}
}
// TestDocumentInjectCharsFor 钉住「注入预算随上下文预算推导」这条口径(表驱动):
// 1M 窗口推导出的 699050 token ⇒ 单份附件可注入 300K+ 字符(远超旧的 64K),
// 但小预算下不跌破历史默认,且绝不超过预算本身。
func TestDocumentInjectCharsFor(t *testing.T) {
t.Parallel()
cases := []struct {
name string
budget int
want int
}{
{"1M 窗口推导出的预算", 699050, 699050 * charsPerToken / documentInjectBudgetShare},
{"fallback 窗口推导出的预算(推导值跌破历史默认 ⇒ 取默认)", 87381, defaultDocumentInjectChars},
{"小预算不超过预算本身", 32768, 32768},
{"预算关闭", 0, defaultDocumentInjectChars},
{"负预算", -1, defaultDocumentInjectChars},
}
for _, c := range cases {
if got := DocumentInjectCharsFor(c.budget); got != c.want {
t.Fatalf("%s: DocumentInjectCharsFor(%d) = %d, want %d", c.name, c.budget, got, c.want)
}
}
if big := DocumentInjectCharsFor(699050); big <= defaultDocumentInjectChars {
t.Fatalf("大窗口下的注入预算 = %d, want > 旧的 64K 卡死值", big)
}
}
// TestEstimateContextTokens_CountsDocuments 是 design/24 §9.5 移交的回归:文档
// 部件必须在 token 估算里占分量,否则纯文档回合被估成 0 token,在压缩里等同于
// 空内容。
@@ -292,12 +380,21 @@ func TestEstimateContextTokens_CountsDocuments(t *testing.T) {
if estimateContextTokens(big) <= estimateContextTokens(small) {
t.Fatalf("大附件估算未变大:%d vs %d", estimateContextTokens(big), estimateContextTokens(small))
}
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument}); got != documentMinTokens {
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument}, 0); got != documentMinTokens {
t.Fatalf("documentPartTokens(未知字节数) = %d, want %d", got, documentMinTokens)
}
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: documentInjectedCharsCap * 4}) /
1; got != documentInjectedCharsCap/charsPerToken {
t.Fatalf("documentPartTokens(超大附件) = %d, want 按注入预算封顶 %d", got, documentInjectedCharsCap/charsPerToken)
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: defaultDocumentInjectChars * 4},
defaultDocumentInjectChars); got != defaultDocumentInjectChars/charsPerToken {
t.Fatalf("documentPartTokens(超大附件) = %d, want 按注入预算封顶 %d", got, defaultDocumentInjectChars/charsPerToken)
}
// 注入预算变大(1M 窗口部署)时,同一个文档部件必须按新预算计入 —— 否则
// 注入 683K 字符的附件在估算里只按 64K 计,压缩永远迟一步。
bigger := DocumentInjectCharsFor(699050)
if bigger <= defaultDocumentInjectChars {
t.Fatalf("DocumentInjectCharsFor(1M 窗口推导出的预算) = %d, want > %d", bigger, defaultDocumentInjectChars)
}
if got, want := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: int64(bigger)}, bigger), bigger/charsPerToken; got != want {
t.Fatalf("按更大注入预算的估算 = %d, want %d", got, want)
}
}
+12
View File
@@ -132,6 +132,7 @@ type Loop struct {
transformContext func(context.Context, []model.Message, int) []model.Message
imageResolver ImageResolver
documentResolver DocumentResolver
documentInjectChars int
log *slog.Logger
}
@@ -157,6 +158,16 @@ func New(opts Options) *Loop {
if compactor == nil {
compactor = defaultCompactor{}
}
docInjectChars := opts.DocumentMaxInjectChars
if docInjectChars <= 0 {
docInjectChars = defaultDocumentInjectChars
}
if d, ok := compactor.(defaultCompactor); ok {
// 内置 compactor 的文档部件估值必须与注入预算同口径:否则注入 683K 字符的
// 附件在压缩估算里只按 64K 计,压缩永远迟一步(切片 T1 §7.2)。
d.documentInjectChars = docInjectChars
compactor = d
}
timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log))
return &Loop{
agent: opts.Agent,
@@ -194,6 +205,7 @@ func New(opts Options) *Loop {
transformContext: opts.TransformContext,
imageResolver: opts.Vision,
documentResolver: opts.DocumentResolver,
documentInjectChars: docInjectChars,
log: sourcedLog(opts.Log),
}
}
+9
View File
@@ -199,6 +199,15 @@ type Options struct {
// 只是没有任何正文被注入 —— 因此即使忘了接线,也不会出现「文档部件抵达
// 编码器」这种 400,更不会出现空上下文。
DocumentResolver DocumentResolver
// DocumentMaxInjectChars 是**单份附件**注入模型可见上下文的字符上限
// (切片 T1 §7.2)。0 选用 defaultDocumentInjectChars(64K 字符);组合根按
// 推导出的上下文预算给出实际值(DocumentInjectCharsFor),使大窗口部署真的
// 能吃到大附件。
//
// 两个后果:① 装配期按它截取注入正文,清单行的续读游标因此**真的是**继续读
// 的起点(sidecar 存的是抽取全文);② 压缩的 token 估算按它给文档部件封顶
// (注入多少就按多少计入),文档回合不会被估成「免费」。
DocumentMaxInjectChars int
// TransformContext 非 nil 时,在每个模型步之前立即被调用,
// 传入组装好的消息列表(记忆注入与任何压缩
// 之后)以及当前轮次号(从 1 开始)。它可以投影、过滤
+25
View File
@@ -25,6 +25,28 @@ type stepOutcome struct {
//(该路径自行构建请求),在 agent 路径上为空。它
// 记录在 llm_call span 的日志事件上。
model string
// promptTokens 是本次调用的输入侧 token 数(提供商上报时,否则 0)。它只用于
// 「输入逼近上下文预算」的 WARN(design/04 附录 A.3 第 5 条,不阻断任何流程)。
promptTokens int
}
// contextPressureRatio 是「单次输入逼近上下文预算」的告警线:达到预算的 90%
// 就记一条 WARN,**不阻断**(design/04 附录 A.3 第 5 条)。它只是让「快溢出了」
// 这件事在日志里可见,压缩仍按自己的预算独立判定。
const contextPressureRatio = 0.9
// reportContextPressure 在本次调用的输入 token 达到预算的 contextPressureRatio
// 时记一条 WARN。预算关闭(<= 0)或提供商没上报用量时不记 —— 没有依据就不吵。
func (l *Loop) reportContextPressure(promptTokens int) {
if l.maxContextTokens <= 0 || promptTokens <= 0 {
return
}
if float64(promptTokens) < float64(l.maxContextTokens)*contextPressureRatio {
return
}
l.log.Warn("model input is near the context budget",
"prompt_tokens", promptTokens, "budget", l.maxContextTokens,
"ratio", contextPressureRatio, "action", "compaction triggers at the budget")
}
// step 运行单次模型调用。配置了 OnStream 回调时,它
@@ -57,6 +79,9 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess
l.spanProp(llmSpan, "model_stop_reason", out.modelStopReason)
}
l.spanProp(llmSpan, "tool_calls", len(out.toolCalls))
// 输入逼近预算的 WARN(不阻断):覆盖流式路径(agent 路径不经本函数的上报,
// promptTokens 为 0 时静默跳过)。
l.reportContextPressure(out.promptTokens)
l.recordLLMDone(llmSpan, out, l.agentFor(ctx))
l.finishSpan(llmSpan, trace.SpanStatusOK)
return out, nil
+2 -2
View File
@@ -89,10 +89,10 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
if calls, ok := decodeToolCalls(resp); ok {
return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls,
modelStopReason: resp.StopReason, model: resp.Model}, nil
modelStopReason: resp.StopReason, model: resp.Model, promptTokens: resp.PromptTokens}, nil
}
return &stepOutcome{final: true, text: resp.Content, stopReason: agent.StopReasonStop,
modelStopReason: resp.StopReason, model: resp.Model}, nil
modelStopReason: resp.StopReason, model: resp.Model, promptTokens: resp.PromptTokens}, nil
}
// checkOutputAsync 在后台运行输出侧护栏检查(裁决 13.4.1:真并发、不阻塞