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

+26 -22
View File
@@ -39,16 +39,18 @@ const (
// 这两处是成对契约,改一处必须改两处,否则模型照清单调用会拿到「未知工具」。
documentReadToolName = "document-read"
// defaultDocumentReadChars 是 limit 缺省时的读取字符数。依据:装配期一次注入
// 最多 64K 字符(documents.MaxExtractChars),本工具是**按需**取用,单次 8000
// 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个上下文」之间的
// 折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
// defaultDocumentReadChars 是 limit 缺省时的读取字符数。本工具是**按需**取用,
// 单次 8000 字符(≈2–3K token)是「读完整一段」与「别让一次工具往返吃掉整个
// 上下文」之间的折中,与 file(5 MiB)/ web-fetch(2 MiB)同属「有上限的取用」。
defaultDocumentReadChars = 8000
// maxDocumentReadChars 是 limit 的上限 = **抽取期预算**(sidecar 正文的物理上限)。
// 它直接取抽取器的常量而不是再写一个字面量:正文在抽取期已被截到
// documents.MaxExtractChars,再大的请求都读不到更多,因此上限就是它。
maxDocumentReadChars = documents.MaxExtractChars
// maxDocumentReadChars 是单次 limit 的**独立上限**(64K 字符)。
//
// 它刻意不再复用 documents.MaxExtractChars(切片 T1 §7.2 第 3 条):那个常量
// 已随「sidecar 存全文」作废,而且「一次工具往返能取多少」与「sidecar 有多长」
// 本来就是两件事 —— sidecar 现在可以有 8 MiB,一次读 8 MiB 进上下文显然不合
// 适。模型要继续读就按 nextOffset 翻页:上限约束的是**一次往返**,不是总量。
maxDocumentReadChars = 64 * 1024
// documentReadNoReason 是 sidecar 说「不可读」却没给出原因时的兜底文案:不静默,
// 也不编造一个具体原因。
@@ -87,14 +89,16 @@ func (d *documentReadTool) Name() string { return documentReadToolName }
// Description implements sdk.Tool. It documents the coordinate system, because the
// model only sees this text plus the attachment list line that pointed it here.
func (d *documentReadTool) Description() string {
return "Read a slice of an already-extracted document (an attachment) by character offset. " +
"Use it with the attachment list line: that line prints a continuation cursor 'offset=N' when the " +
"injected text was cut, and 'offset' here is that same 0-based character offset into the extracted " +
"text (the CSV/TSV summary line, when present, is part of it). Each result reports totalChars and, " +
"when more text is available after this slice, truncated=true plus nextOffset to continue from. " +
"Only server-issued document names from the attachment list can be read; nothing is re-extracted, " +
"so the extracted text is the same text that was injected. Note that the extraction itself is " +
"capped: when extractTruncated is true the extracted text ends there but the original document is longer."
return fmt.Sprintf("Read a slice of an already-extracted document (an attachment) by character offset. "+
"Use it with the attachment list line: that line prints a continuation cursor 'offset=N' when the "+
"injected text was cut, and 'offset' here is that same 0-based character offset into the extracted "+
"text (the CSV/TSV summary line, when present, is part of it). Each result reports totalChars and, "+
"when more text is available after this slice, truncated=true plus nextOffset to continue from. "+
"Only server-issued document names from the attachment list can be read, and nothing is ever "+
"re-extracted, so the extracted text is the very text the injection was cut from. Two caps are "+
"separate: this call returns at most 'limit' characters (default %d, capped at %d), while the "+
"extraction itself is bounded by a safety limit - when extractTruncated is true the extracted text "+
"ends there and the original document is longer.", defaultDocumentReadChars, maxDocumentReadChars)
}
// Parameters implements sdk.Tool.
@@ -137,10 +141,11 @@ type documentReadResult struct {
Truncated bool `json:"truncated"`
// NextOffset 是续读游标(= Offset + 本次返回的字符数);没有更多内容时为 0 并省略。
NextOffset int `json:"nextOffset,omitempty"`
// ExtractTruncated 报告**抽取期**是否已按预算截断(sidecar 的 truncated):为 true 时
// 「正文到此为止」不等于「原文件到此为止」—— 预算之外的内容不在 sidecar 里
//(design/24 §9.6 第 3 条:64K 预算落在抽取期)。这条事实必须让模型看得见,
// 否则它会拿「读到末尾」当成「文件只有这么长」。
// ExtractTruncated 报告**抽取期**是否已撞上安全上限(sidecar 的 truncated,8 MiB
// 文本):为 true 时「正文到此为止」不等于「原文件到此为止」—— 上限之外的内容
// 不在 sidecar 里。这条事实必须让模型看得见,否则它会拿「读到末尾」当成
// 「文件只有这么长」。注意它与「本回合装配期有没有被注入预算截断」是两件事:
// 后者体现在清单行的续读游标上,本字段不受它影响。
ExtractTruncated bool `json:"extractTruncated,omitempty"`
// Text 是本次读到的正文(sidecar 第一行元数据之后的部分)。
Text string `json:"text"`
@@ -232,8 +237,7 @@ func documentReadWindow(m map[string]any) (int, int, error) {
// documentReadSlice 返回 s 从第 offset 个字符起的至多 limit 个字符,以及其后是否还有
// 内容。单位是**字符(rune)**而不是字节 —— 与清单行的续读游标同一口径(loop 给的
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。s 的长度受
// 抽取预算封顶(documents.MaxExtractChars),故这里的一次转换是有界的。
// 是「已注入的字符数」),因此多字节字符既不会被算错,也不会被切成两半。
func documentReadSlice(s string, offset, limit int) (string, bool) {
runes := []rune(s)
if offset >= len(runes) {