feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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:
1 parent
5b2b385524
commit
72b95df22b
39 files changed
+2463
-344
No files matched your search
+28
-7
@@ -97,6 +97,14 @@ LLM_REASONING_KIND=
|
||||
# ⚠️ 非法值(非 bool,如 maybe)= 启动即报错,不静默回退。
|
||||
# LLM_MULTIMODAL=false
|
||||
|
||||
# 声明该端点的【模型上下文窗口】(token)。窗口是上下文压缩预算的推导依据:
|
||||
# 未设置 CONTEXT_MAX_TOKENS 时 budget = min(窗口 × CONTEXT_BUDGET_RATIO, 窗口 − 输出保留)。
|
||||
# ⚠️ 本仓没有任何 provider 侧的窗口发现代码(GET /models 的自动发现另立设计),窗口只能声明:
|
||||
# 不声明就回落 131072(128 Ki)并记一条 WARN,默认模型的 1M 窗口因此吃不到。
|
||||
# 它必须与 models.json 组的 contextWindow 一致(那一处是面板一等来源,本键只给 .env 合成的组播种)。
|
||||
# 负数 = 启动即报错。
|
||||
# LLM_CONTEXT_WINDOW=1048576
|
||||
|
||||
# Provider = anthropic:Anthropic Messages /v1/messages(tool_use.input 为结构化对象,工具循环更稳)
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
@@ -199,13 +207,26 @@ CAPACITY_L3_CACHE_TTL=5m
|
||||
# 可配置);模型派发时可用 subagent_spawn 的 timeout_seconds 参数按调用覆盖。
|
||||
SUBAGENT_TIMEOUT_SEC=600
|
||||
|
||||
# ---- 护栏与上下文预算(design/04 §1.3 / §5) ----
|
||||
# 上下文溢出压缩预算(token,离线估算值)。默认启用:会话估算上下文超过它时,
|
||||
# 更早的轮次被摘要成一条前导摘要消息,近期尾部(含尾部工具结果)原样保留。
|
||||
# 默认 32768 = loop.DefaultMaxContextTokens:默认模型(gpt-4o)窗口 128K,
|
||||
# 它只占四分之一,给系统提示词/工具 schema/本轮输出留足余量,且远高于日常对话
|
||||
# 体量(正常聊天几乎不触发压缩)。设 0(或负数)= 关闭压缩,行为与接线前一致。
|
||||
CONTEXT_MAX_TOKENS=32768
|
||||
# ---- 护栏与上下文预算(design/04 §1.3 / §5、附录 A.3) ----
|
||||
# 上下文溢出压缩预算(token,离线估算值)。会话估算上下文超过它时,更早的轮次被摘要成
|
||||
# 一条前导摘要消息,近期尾部(含尾部工具结果)原样保留。三种取值:
|
||||
# · 未设置(默认,即把本行注释掉)= 按模型窗口推导:
|
||||
# budget = min(窗口 × CONTEXT_BUDGET_RATIO, 窗口 − 输出保留)
|
||||
# 输出保留 = max(32768, 本组 maxTokens);窗口未声明 ⇒ 回落 131072 并记一条 WARN。
|
||||
# · > 0 = 显式覆盖(原样使用)
|
||||
# · 0 / 负数 = 关闭压缩(行为与本条接线前逐字节一致)
|
||||
# ⚠️ 旧模板里的 CONTEXT_MAX_TOKENS=32768 取自「gpt-4o 128K 窗口的四分之一」,那个前提已失效
|
||||
# (默认模型窗口 1M)。若你的 .env 里还留着它,它就是【显式覆盖 32768】,会盖掉按窗口推导
|
||||
# (1M 窗口 ⇒ 699050 token)——要吃到推导,把那一行注释掉。固定低阈值会既丢上下文、又因反复
|
||||
# 压缩作废 KV 缓存前缀而反复烧钱(附录 A.2)。
|
||||
# CONTEXT_MAX_TOKENS=
|
||||
# 推导比例:预算占窗口的比例,主人 2026-10-04 裁决取 1/2–3/4 之间,默认 2/3。
|
||||
# 写法:小数(0.5)或分数(1/2)都接受;非法值 = 记一条 WARN 并回落 2/3。
|
||||
# CONTEXT_BUDGET_RATIO=2/3
|
||||
# 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。
|
||||
# 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。
|
||||
# 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。
|
||||
# DOCUMENT_MAX_INJECT_CHARS=
|
||||
# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏
|
||||
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key /
|
||||
# bearer token / 密码 / 私钥)为阻断档、请求与响应各有长度上限,这两项是代码
|
||||
|
||||
+77
-10
@@ -1,16 +1,83 @@
|
||||
package main
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
// contextBudget 返回循环的上下文溢出压缩预算(CONTEXT_MAX_TOKENS,
|
||||
// design/04 §1.3)。**非正 = 关闭压缩**,行为与本条接线前逐字节一致;
|
||||
// 未配置时由 config 层给出 DefaultContextMaxTokens(即默认启用)。
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件是**上下文预算的唯一决策点**(组合根):把「配置 + 生效模型组」摊平成
|
||||
// config.ContextBudgetInput,喂给纯函数 config.DeriveContextBudget,再把结论交给
|
||||
// loop.Options.MaxContextTokens 与 loop.Options.DocumentMaxInjectChars。
|
||||
//
|
||||
// 取值只从配置来,组合根不做二次猜测:阈值语义(含「0 关掉」)由配置层
|
||||
// 一处定义,组合根只负责把它交给 loop.Options.MaxContextTokens。
|
||||
func (a *app) contextBudget() int {
|
||||
if a.cfg == nil {
|
||||
return config.DefaultContextMaxTokens
|
||||
// 为什么推导放在这里而不是配置层:窗口与 maxTokens 都在 models.json 的**生效组**
|
||||
// 上(面板可编辑、热重载),而 CONTEXT_MAX_TOKENS 在 .env。两个来源的会合点只有
|
||||
// 组合根,推导函数本身因此保持纯粹、可表驱动测试。
|
||||
//
|
||||
// 语义(design/04 §1.3 + 附录 A.3):
|
||||
// - CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导 budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||||
// - > 0 ⇒ 显式覆盖;0 / 负数 ⇒ 关闭压缩(与接线前一致);
|
||||
// - 窗口未声明 ⇒ 回落 config.DefaultModelContextWindow 并记**恰好一条** WARN;
|
||||
// - 比例非法(CONTEXT_BUDGET_RATIO)⇒ 记一条带原始取值的 WARN 并回落 2/3。
|
||||
|
||||
// ctxBudget 是组合根推导出的两项预算:循环的溢出压缩预算与单份附件注入预算。
|
||||
type ctxBudget struct {
|
||||
// Budget 是压缩预算的完整结论(含推导来源,供日志与断言)。
|
||||
Budget config.ContextBudget
|
||||
// DocumentInjectChars 是单份附件注入上下文的字符上限(T1 §7.2)。
|
||||
DocumentInjectChars int
|
||||
}
|
||||
return a.cfg.Context.MaxTokens
|
||||
|
||||
// contextBudget 计算本次运行生效的上下文预算。envPath 是启动时合并的 .env 路径
|
||||
// (比例键的读取与其余配置同一套优先级;空串表示不看 .env)。
|
||||
//
|
||||
// 模型组取**生效组**(models.json 下标 0,或 .env 合成的组):压缩预算与注入预算
|
||||
// 都是循环级的单一值,而生效组才是新请求默认用的那个。
|
||||
func (a *app) contextBudget(envPath string) ctxBudget {
|
||||
in := config.ContextBudgetInput{}
|
||||
docOverride := 0
|
||||
if a.cfg != nil {
|
||||
in.Override = a.cfg.Context.MaxTokens
|
||||
in.OverrideConfigured = a.cfg.Context.MaxTokensSet
|
||||
docOverride = a.cfg.Context.DocumentMaxInjectChars
|
||||
}
|
||||
if a.llm != nil {
|
||||
g := a.llm.Group()
|
||||
in.Window = g.ContextWindow
|
||||
in.GroupMaxTokens = g.MaxTokens
|
||||
}
|
||||
return deriveContextBudget(sourcedWiringLog(a.log), envPath, in, docOverride)
|
||||
}
|
||||
|
||||
// deriveContextBudget 是 contextBudget 的可测内核:取值来源收成显式参数,
|
||||
// 日志与「恰好一条 WARN」也在这里(测试用捕获型 handler 断言条数)。
|
||||
func deriveContextBudget(log *slog.Logger, envPath string, in config.ContextBudgetInput, docOverride int) ctxBudget {
|
||||
ratio, err := config.LoadContextBudgetRatio(envPath)
|
||||
if err != nil {
|
||||
// 非法比例:记一条带原始取值的 WARN 并回落默认(AGENTS §10:不许静默失效)。
|
||||
log.Warn("context budget ratio invalid, falling back",
|
||||
"env", config.ContextBudgetRatioEnv, "error", err,
|
||||
"fallback_ratio", config.DefaultContextBudgetRatio)
|
||||
ratio = config.DefaultContextBudgetRatio
|
||||
}
|
||||
in.Ratio = ratio
|
||||
budget := config.DeriveContextBudget(in)
|
||||
// 单份附件注入预算:显式覆盖优先,否则随推导出的预算走。
|
||||
docChars := docOverride
|
||||
if docChars <= 0 {
|
||||
docChars = loop.DocumentInjectCharsFor(budget.Tokens)
|
||||
}
|
||||
if budget.Mode == config.ContextBudgetDerived && !budget.WindowDeclared {
|
||||
// 窗口未声明:**恰好一条** WARN(每次启动只推导一次)。
|
||||
log.Warn("model context window not declared, falling back",
|
||||
"env", "LLM_CONTEXT_WINDOW", "json_key", "contextWindow",
|
||||
"window", budget.Window, "hint", "声明窗口才能吃满模型的真实窗口")
|
||||
}
|
||||
log.Info("context budget",
|
||||
"mode", string(budget.Mode), "tokens", budget.Tokens,
|
||||
"window", budget.Window, "window_declared", budget.WindowDeclared,
|
||||
"ratio", budget.Ratio, "output_reserve", budget.OutputReserve,
|
||||
"document_inject_chars", docChars)
|
||||
return ctxBudget{Budget: budget, DocumentInjectChars: docChars}
|
||||
}
|
||||
+237
-42
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,10 +17,14 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定 P0-11:上下文溢出压缩在生产里**真的启用**,且阈值来自
|
||||
// CONTEXT_MAX_TOKENS。断言落在「模型实际看到的请求消息」上:
|
||||
// 预算小时长对话被压成「一条前导摘要 + 近期尾部」,摘要消息确实进了 prompt;
|
||||
// 预算为 0 时行为与接线前逐条一致(一条消息都不少)。
|
||||
// 本文件锁定上下文预算的取值链(design/04 §1.3 + 附录 A.3):
|
||||
//
|
||||
// - **窗口声明**(models.json 组的 contextWindow / LLM_CONTEXT_WINDOW)与推导
|
||||
// budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||||
// - CONTEXT_MAX_TOKENS 的三态(未设置 ⇒ 推导 / >0 ⇒ 覆盖 / 0 或负 ⇒ 关闭);
|
||||
// - 窗口未声明时**恰好一条** WARN;
|
||||
// - 单份附件的注入预算随推导预算走,且真的让「清单游标 → document-read」这条
|
||||
// 续读链路跑通(S4 实测的断裂,切片 T1 §7.2)。
|
||||
|
||||
// loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/
|
||||
// compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。
|
||||
@@ -56,8 +63,8 @@ func longConversation() []model.Message {
|
||||
}
|
||||
|
||||
// runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致:
|
||||
// MaxContextTokens 取自 a.contextBudget(),Compactor 不注入 —— 由 loop 选用
|
||||
// 内置的结构化 compactor),返回模型实际看到的请求。
|
||||
// MaxContextTokens 取自推导结论,Compactor 不注入 —— 由 loop 选用内置的结构化
|
||||
// compactor),返回模型实际看到的请求。
|
||||
func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -104,41 +111,223 @@ func loopText(m model.Message) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault 锁定取值链:
|
||||
// 配置层默认 == loop.DefaultMaxContextTokens(两个常量不许漂移),
|
||||
// 组合根读的就是配置值(含「0 = 关闭」这条语义)。
|
||||
func TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault(t *testing.T) {
|
||||
// 环境里若已有 CONTEXT_MAX_TOKENS,配置层会优先用它,默认值断言就不成立;
|
||||
// 用 t.Setenv 钉空串也不行(空串会被解析成 0),因此这里显式清掉再还原。
|
||||
if prev, had := os.LookupEnv("CONTEXT_MAX_TOKENS"); had {
|
||||
_ = os.Unsetenv("CONTEXT_MAX_TOKENS")
|
||||
t.Cleanup(func() { _ = os.Setenv("CONTEXT_MAX_TOKENS", prev) })
|
||||
// capturingLog 返回输出 JSON 记录的 logger 与缓冲:这里要按**条数**断言 WARN
|
||||
// (「窗口未声明恰好一条」),文本子串数不出来。
|
||||
func capturingLog() (*slog.Logger, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("k", 40))
|
||||
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "absent.env"))
|
||||
// logRecords 把缓冲里的 JSON 记录逐条解出来。
|
||||
func logRecords(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
|
||||
}
|
||||
|
||||
// countRecords 数出消息里包含 substr 的记录条数(只看 msg 与 level)。
|
||||
func countRecords(records []map[string]any, level, substr string) int {
|
||||
n := 0
|
||||
for _, rec := range records {
|
||||
msg, _ := rec["msg"].(string)
|
||||
if level != "" && rec["level"] != level {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(msg, substr) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// envWindowStack 造一个「生效组声明了窗口」的模型栈:models.json 不存在时
|
||||
// LoadModels 会回落到 .env 合成的组,而 LLM_CONTEXT_WINDOW 正是该组的窗口来源。
|
||||
func envWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
return model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{
|
||||
Provider: "openai",
|
||||
BaseURL: "https://api.example/v1",
|
||||
Model: "test-model",
|
||||
ContextWindow: window,
|
||||
}, testLog())
|
||||
}
|
||||
|
||||
// modelsWindowStack 造一个「生效组是 models.json 里那一组」的模型栈:组级
|
||||
// contextWindow / maxTokens 只在 models.json 里有出处,因此这里真写一份文件。
|
||||
func modelsWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
raw, err := json.Marshal([]map[string]any{{
|
||||
"id": "deepseek", "provider": "openai", "baseUrl": "https://api.example/v1",
|
||||
"apiKey": "sk-test", "model": "test-model",
|
||||
"contextWindow": window, "maxTokens": maxTokens,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load: %v", err)
|
||||
t.Fatalf("marshal models.json: %v", err)
|
||||
}
|
||||
if cfg.Context.MaxTokens != config.DefaultContextMaxTokens {
|
||||
t.Fatalf("CONTEXT_MAX_TOKENS 默认值 = %d,want %d", cfg.Context.MaxTokens, config.DefaultContextMaxTokens)
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatalf("write models.json: %v", err)
|
||||
}
|
||||
if config.DefaultContextMaxTokens != loop.DefaultMaxContextTokens {
|
||||
t.Fatalf("默认值与 loop 的常量漂移:%d vs %d",
|
||||
config.DefaultContextMaxTokens, loop.DefaultMaxContextTokens)
|
||||
}
|
||||
if got := (&app{cfg: cfg}).contextBudget(); got != loop.DefaultMaxContextTokens {
|
||||
t.Fatalf("contextBudget() = %d,want %d", got, loop.DefaultMaxContextTokens)
|
||||
return model.NewRuntimeLLM(path, config.LLMConfig{}, testLog())
|
||||
}
|
||||
|
||||
// 显式取值原样透传;0 = 关闭。
|
||||
cfg.Context.MaxTokens = 4096
|
||||
if got := (&app{cfg: cfg}).contextBudget(); got != 4096 {
|
||||
t.Fatalf("contextBudget() = %d,want 4096", got)
|
||||
// TestContextBudget_DerivedFromTheDeclaredWindow 钉住推导链的接线:窗口取自
|
||||
// **生效模型组**,预算按附录 A.3 的公式算,比例来自 CONTEXT_BUDGET_RATIO。
|
||||
func TestContextBudget_DerivedFromTheDeclaredWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != 699050 || got.Budget.Mode != config.ContextBudgetDerived {
|
||||
t.Fatalf("1M 窗口的预算 = %+v, want 699050/derived", got.Budget)
|
||||
}
|
||||
cfg.Context.MaxTokens = 0
|
||||
if got := (&app{cfg: cfg}).contextBudget(); got != 0 {
|
||||
t.Fatalf("contextBudget() = %d,want 0(关闭)", got)
|
||||
// 单份附件注入预算随推导预算走:1M 窗口下远大于旧的 64K 卡死值。
|
||||
if got.DocumentInjectChars <= 64*1024 {
|
||||
t.Fatalf("1M 窗口下的注入预算 = %d, want > 64K", got.DocumentInjectChars)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("窗口已声明却记了 %d 条「未声明」WARN", n)
|
||||
}
|
||||
|
||||
// 组级 maxTokens=384K(思考模式上限)⇒ 输出保留吃满 ⇒ min(699050, 1M−384K)。
|
||||
a.llm = modelsWindowStack(t, 1048576, 384*1024)
|
||||
got = a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != 655360 || got.Budget.OutputReserve != 384*1024 {
|
||||
t.Fatalf("带输出保留的预算 = %+v, want 655360 / reserve 393216", got.Budget)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_UndeclaredWindowWarnsExactlyOnce 钉住回落与「恰好一条 WARN」:
|
||||
// 没声明窗口的部署照常工作,但必须被告知「要吃到 1M 得声明窗口」,且只被告知一次。
|
||||
func TestContextBudget_UndeclaredWindowWarnsExactlyOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 0, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Window != config.DefaultModelContextWindow || got.Budget.WindowDeclared {
|
||||
t.Fatalf("未声明窗口 = %+v, want 回落 %d 且 WindowDeclared=false",
|
||||
got.Budget, config.DefaultModelContextWindow)
|
||||
}
|
||||
if got.Budget.Tokens != 87381 {
|
||||
t.Fatalf("回落窗口下的预算 = %d, want 87381(131072 × 2/3 截尾)", got.Budget.Tokens)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "not declared"); n != 1 {
|
||||
t.Fatalf("「窗口未声明」WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||
}
|
||||
// WARN 必须点出两个声明入口,否则主人不知道该改哪儿。
|
||||
warn := ""
|
||||
for _, rec := range records {
|
||||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "not declared") {
|
||||
warn = msg
|
||||
if rec["env"] != "LLM_CONTEXT_WINDOW" || rec["json_key"] != "contextWindow" {
|
||||
t.Fatalf("WARN 没点出声明入口: %v", rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if warn == "" {
|
||||
t.Fatal("没有找到窗口未声明的 WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_OverrideAndOff 钉住 CONTEXT_MAX_TOKENS 的三态:>0 覆盖、
|
||||
// 显式 0 关闭、负值关闭 —— 且关闭时**不推导**(窗口未声明也不该冒出 WARN)。
|
||||
func TestContextBudget_OverrideAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
a.cfg.Context.MaxTokens = 600
|
||||
if got := a.contextBudget(envPath); got.Budget.Tokens != 600 || got.Budget.Mode != config.ContextBudgetOverride {
|
||||
t.Fatalf("显式覆盖 = %+v, want 600/override", got.Budget)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("显式覆盖与窗口无关,却记了 %d 条「未声明」WARN", n)
|
||||
}
|
||||
|
||||
for _, override := range []int{0, -1} {
|
||||
a.cfg.Context.MaxTokens = override
|
||||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(0 与「未设置」必须分开)
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != override || got.Budget.Mode != config.ContextBudgetOff {
|
||||
t.Fatalf("override=%d ⇒ %+v, want off", override, got.Budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_InvalidRatioWarnsAndFallsBack 钉住非法比例的处理:记一条带
|
||||
// 原始取值的 WARN 并回落 2/3,绝不静默生效(AGENTS §10)。
|
||||
func TestContextBudget_InvalidRatioWarnsAndFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "test.env")
|
||||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=四分之三\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Ratio != config.DefaultContextBudgetRatio || got.Budget.Tokens != 699050 {
|
||||
t.Fatalf("非法比例下的预算 = %+v, want 回落 2/3 ⇒ 699050", got.Budget)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "ratio invalid"); n != 1 {
|
||||
t.Fatalf("非法比例的 WARN 条数 = %d, want 1(记录:%v)", n, records)
|
||||
}
|
||||
|
||||
// 合法比例真的生效:1/2 × 1M = 524288。
|
||||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=1/2\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
if got := a.contextBudget(envPath); got.Budget.Tokens != 524288 {
|
||||
t.Fatalf("1/2 比例下的预算 = %d, want 524288", got.Budget.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_DocumentInjectCharsOverride 钉住注入预算的显式覆盖优先:
|
||||
// DOCUMENT_MAX_INJECT_CHARS 是「至少可配」的落点(任务书 §2 第 7 条)。
|
||||
func TestContextBudget_DocumentInjectCharsOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
a.cfg.Context.DocumentMaxInjectChars = 4096
|
||||
got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if got.DocumentInjectChars != 4096 {
|
||||
t.Fatalf("显式注入预算 = %d, want 4096", got.DocumentInjectChars)
|
||||
}
|
||||
if got.Budget.Tokens != 699050 {
|
||||
t.Fatalf("注入预算的覆盖不该影响压缩预算:%d", got.Budget.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_DefaultsToNoiseFreeLoopDefault 锁定取值链的兜底:显式覆盖与
|
||||
// 推导都不适用时(这里用显式覆盖举最小的例子),组合根读的就是配置值本身。
|
||||
func TestContextBudget_DefaultsToNoiseFreeLoopDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = config.MinContextOutputReserve
|
||||
a.cfg.Context.MaxTokensSet = true
|
||||
|
||||
if got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")); got.Budget.Tokens != config.MinContextOutputReserve {
|
||||
t.Fatalf("contextBudget = %+v, want %d", got.Budget, config.MinContextOutputReserve)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("覆盖态下不该有窗口 WARN(记了 %d 条)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +340,7 @@ func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *test
|
||||
a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量
|
||||
msgs := longConversation()
|
||||
|
||||
req := runWithBudget(t, a.contextBudget(), msgs)
|
||||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
|
||||
|
||||
summaries := summaryMessages(req)
|
||||
if len(summaries) != 1 {
|
||||
@@ -173,16 +362,18 @@ func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:预算为 0
|
||||
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:显式 0
|
||||
// (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求,
|
||||
// 且没有任何摘要消息。
|
||||
func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &app{log: testLog(), cfg: &config.Config{}} // Context.MaxTokens 零值 = 关闭
|
||||
a := &app{log: testLog(), cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = 0
|
||||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(未设置会走推导)
|
||||
msgs := longConversation()
|
||||
|
||||
req := runWithBudget(t, a.contextBudget(), msgs)
|
||||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
|
||||
|
||||
if len(req.Messages) != len(msgs)+1 {
|
||||
t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1)
|
||||
@@ -195,10 +386,9 @@ func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
|
||||
// TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。
|
||||
//
|
||||
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
|
||||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此 P0-5 / P0-11 的
|
||||
// 「组合根确实走了这两条缝」只能在源码层钉住:wire_runtime.go 必须调用
|
||||
// a.buildGuardrailPipeline(),并且把 MaxContextTokens 取自 a.contextBudget()。
|
||||
// 有人把这两行改回空管线 / 不设预算时,这里立刻红。
|
||||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
|
||||
// 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、
|
||||
// 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。
|
||||
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -206,7 +396,12 @@ func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_runtime.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"a.buildGuardrailPipeline()", "a.contextBudget()"} {
|
||||
for _, want := range []string{
|
||||
"a.buildGuardrailPipeline()",
|
||||
"a.contextBudget(envPath)",
|
||||
"MaxContextTokens: ctxBudget.Budget.Tokens",
|
||||
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
|
||||
} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// 本文件是切片 T1 §7.2 的**端到端**成对契约(S4 实测断裂的正面用例):
|
||||
// 装配期注入预算与文档读侧共用同一套字符坐标系,因此清单行给的续读游标真的能
|
||||
// 被 document-read 用起来。预算推导本身的用例在 wire_context_test.go。
|
||||
|
||||
// docE2EName 是合法存储名形态(32 位十六进制 + .txt)。
|
||||
const docE2EName = "0123456789abcdef0123456789abcdef.txt"
|
||||
|
||||
// docBudgetFixture 是一份端到端夹具:隔离数据目录 + 已落盘的正文 + 生产解析器 +
|
||||
// 生产 document-read 工具 + 一个能捕获模型请求的循环。
|
||||
type docBudgetFixture struct {
|
||||
cfg *config.Config
|
||||
body string
|
||||
ad *compactProbeAdapter
|
||||
read sdk.Tool
|
||||
cursor int
|
||||
}
|
||||
|
||||
// newDocBudgetFixture 造夹具并跑**一次真实的回合**,返回模型看到的清单游标。
|
||||
func newDocBudgetFixture(t *testing.T, injectBudget int) *docBudgetFixture {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||
t.Fatalf("建上传目录: %v", err)
|
||||
}
|
||||
fx := &docBudgetFixture{cfg: &config.Config{DataDir: dataDir}, body: strings.Repeat("abcdefghij", 20000)}
|
||||
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(fx.body), 0o600); err != nil {
|
||||
t.Fatalf("写上传件: %v", err)
|
||||
}
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||||
DocumentMaxInjectChars: injectBudget,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
|
||||
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(fx.body))})
|
||||
if _, err := l.Run(context.Background(), "sess-doc", []model.Message{msg}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(adapter.reqs) == 0 {
|
||||
t.Fatal("没有捕获到模型请求")
|
||||
}
|
||||
fx.ad = adapter
|
||||
|
||||
tools, err := iotools.New(fx.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
fx.read = documentReadToolByName(t, tools)
|
||||
|
||||
req := adapter.reqs[len(adapter.reqs)-1]
|
||||
prompt := loopText(req.Messages[len(req.Messages)-1])
|
||||
fx.cursor, _ = cursorFromManifest(t, prompt)
|
||||
return fx
|
||||
}
|
||||
|
||||
// TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget 是切片 T1 §7.2 的
|
||||
// **端到端**成对契约(S4 实测断裂的正面用例):正文远大于注入预算时
|
||||
//
|
||||
// ①清单行带 truncated + 续读游标(游标 = 实际注入的字符数);
|
||||
// ②把清单行里那个游标原样交给 `document-read`,拿到的是注入预算**之外**的正文。
|
||||
//
|
||||
// 三个角色都是生产实现:装配期是真实的 loop.resolveDocuments,sidecar 由真实的
|
||||
// documents.Resolver 写出,读取走真实的 document-read 工具。唯一被单独钉住的是
|
||||
// 「注入预算 = 1000 字符」,那正是本切片新增的可配置项。
|
||||
func TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
const injectBudget = 1000
|
||||
fx := newDocBudgetFixture(t, injectBudget)
|
||||
|
||||
if fx.cursor != injectBudget {
|
||||
t.Fatalf("清单游标 = %d, want %d(= 实际注入的字符数)", fx.cursor, injectBudget)
|
||||
}
|
||||
prompt := loopText(fx.ad.reqs[len(fx.ad.reqs)-1].Messages[len(fx.ad.reqs[len(fx.ad.reqs)-1].Messages)-1])
|
||||
_, injected := cursorFromManifest(t, prompt)
|
||||
if injected != fx.body[:injectBudget] {
|
||||
t.Fatalf("注入正文 = %d 字符,want 正文的前 %d 字符", len(injected), injectBudget)
|
||||
}
|
||||
|
||||
// ② 照游标续读:拿到的是注入预算之外的正文(旧实现这里恒为空 —— 断裂所在)。
|
||||
first := callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": fx.cursor})
|
||||
if first["offset"] != float64(fx.cursor) {
|
||||
t.Fatalf("续读 offset = %v, want %d", first["offset"], fx.cursor)
|
||||
}
|
||||
text, _ := first["text"].(string)
|
||||
if text == "" {
|
||||
t.Fatal("按清单游标续读读到了空正文 —— 清单给的承诺是假的")
|
||||
}
|
||||
if want := fx.body[fx.cursor : fx.cursor+len(text)]; text != want {
|
||||
t.Fatalf("续读正文不是注入预算之外的那一段:\n got %q\nwant %q", text, want)
|
||||
}
|
||||
|
||||
// 一路翻页到底:拼起来必须正好是注入预算之外的全文(两边同一坐标系)。
|
||||
joined := text
|
||||
offset := fx.cursor + len(text)
|
||||
for i := 0; i < 100 && first["truncated"] == true; i++ {
|
||||
first = callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": offset})
|
||||
page, _ := first["text"].(string)
|
||||
joined += page
|
||||
offset += len(page)
|
||||
}
|
||||
if joined != fx.body[fx.cursor:] {
|
||||
t.Fatalf("分页续读拼出 %d 字符,want %d(注入预算之外的全文)", len(joined), len(fx.body)-fx.cursor)
|
||||
}
|
||||
if total, _ := first["totalChars"].(float64); int(total) != len(fx.body) {
|
||||
t.Fatalf("totalChars = %v, want 全文 %d 字符", first["totalChars"], len(fx.body))
|
||||
}
|
||||
}
|
||||
|
||||
// cursorFromManifest 从模型看到的上下文里取出清单行与续读游标(offset=N)。它刻意
|
||||
// **解析文本**而不是读内部字段:模型能看到的就只有这一行,游标能不能被模型用起来,
|
||||
// 正是这条契约要证明的事。返回游标与注入块(清单行 + 换行 + 注入正文)。
|
||||
func cursorFromManifest(t *testing.T, prompt string) (int, string) {
|
||||
t.Helper()
|
||||
start := strings.Index(prompt, "[附件 ")
|
||||
if start < 0 {
|
||||
t.Fatalf("上下文里没有清单行:%q", prompt)
|
||||
}
|
||||
block := prompt[start:]
|
||||
line, injected, ok := strings.Cut(block, "]\n")
|
||||
if !ok {
|
||||
t.Fatalf("清单行没有闭合或没有正文行:%q", block)
|
||||
}
|
||||
if !strings.Contains(line, "已截断") {
|
||||
t.Fatalf("清单行没有如实标注截断:%q", line)
|
||||
}
|
||||
i := strings.Index(line, "offset=")
|
||||
if i < 0 {
|
||||
t.Fatalf("清单行没有续读游标:%q", line)
|
||||
}
|
||||
digits := line[i+len("offset="):]
|
||||
end := 0
|
||||
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
n, err := strconv.Atoi(digits[:end])
|
||||
if err != nil {
|
||||
t.Fatalf("续读游标不是整数:%q", digits[:end])
|
||||
}
|
||||
return n, injected
|
||||
}
|
||||
|
||||
// documentReadToolByName 在真实工具集里找出 document-read 工具。
|
||||
func documentReadToolByName(t *testing.T, tools []sdk.Tool) sdk.Tool {
|
||||
t.Helper()
|
||||
for _, tl := range tools {
|
||||
if tl.Name() == "document-read" {
|
||||
return tl
|
||||
}
|
||||
}
|
||||
t.Fatal("工具集里没有 document-read")
|
||||
return nil
|
||||
}
|
||||
|
||||
// callDocumentRead 执行一次 document-read 并把返回的 JSON 文本解回 map。
|
||||
func callDocumentRead(t *testing.T, tl sdk.Tool, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := tl.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("document-read: %v", err)
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||||
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -252,6 +252,12 @@ func (a *app) wireRuntime() error {
|
||||
approver := audit.NewApprover(a.log)
|
||||
approvalGate := a.buildApprovalGate(approver)
|
||||
|
||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
|
||||
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
|
||||
ctxBudget := a.contextBudget(envPath)
|
||||
|
||||
a.loop = loop.New(loop.Options{
|
||||
Agent: defaultAgent,
|
||||
Agents: agents,
|
||||
@@ -277,10 +283,10 @@ func (a *app) wireRuntime() error {
|
||||
PersonaKnowledgeFor: a.personaKnowledgeFor(envPath),
|
||||
Timezone: a.cfg.Timezone,
|
||||
MaxRounds: defaultMaxRounds,
|
||||
// 上下文溢出压缩:预算取自 CONTEXT_MAX_TOKENS(非正 = 关闭,
|
||||
// 与接线前一致),默认启用(见 wire_context.go)。Compactor 不注入,
|
||||
// 由 loop 选用内置的结构化 compactor(离线、不调模型)。
|
||||
MaxContextTokens: a.contextBudget(),
|
||||
MaxContextTokens: ctxBudget.Budget.Tokens,
|
||||
// 单份附件的注入预算随推导出的上下文预算走(T1 §7.2):1M 窗口下大附件
|
||||
// 才真的进得来,而清单行的续读游标也因此真的是继续读的起点。
|
||||
DocumentMaxInjectChars: ctxBudget.DocumentInjectChars,
|
||||
WorkingMemory: memory.NewWorkingMemory(0),
|
||||
OnStream: a.streamChunk,
|
||||
Vision: visionResolver,
|
||||
|
||||
@@ -132,94 +132,6 @@ type DBConfig struct {
|
||||
PGURL string `env:"DB_PG_URL"`
|
||||
}
|
||||
|
||||
// LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
|
||||
// wire protocol; every provider has its own independent base/key/model/fallback
|
||||
// so multiple vendors can coexist.
|
||||
type LLMConfig struct {
|
||||
// Provider declares which protocol to use: "openai" (OpenAI-compatible
|
||||
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
|
||||
Provider string `env:"LLM_PROVIDER" default:"openai"`
|
||||
|
||||
// OpenAI-compatible provider (default). Kept flat for backward compatibility
|
||||
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
|
||||
BaseURL string `env:"LLM_BASE_URL"`
|
||||
APIKey string `env:"LLM_API_KEY"`
|
||||
Model string `env:"LLM_MODEL"`
|
||||
Fallback string `env:"LLM_FALLBACK_MODEL"`
|
||||
|
||||
// VisionModel is the dedicated model behind the vision tool (image
|
||||
// understanding). It is the tool's model, not the main model's ability: the
|
||||
// per-persona/per-request routing decision belongs to the vision Router, and
|
||||
// the main model's own image input capability is declared by the active
|
||||
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
|
||||
VisionModel string `env:"LLM_VISION_MODEL"`
|
||||
|
||||
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
|
||||
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
|
||||
// 的「独立视觉模型 + OCR 模型」)。
|
||||
OCRModel string `env:"LLM_OCR_MODEL"`
|
||||
|
||||
// Multimodal 声明**主模型自身**能否收图:models.json 组 `multimodal` 的 env
|
||||
// 等价物,只作用于 .env 合成的组,默认 false(语义与分工见 GroupFromEnv)。
|
||||
Multimodal bool `env:"LLM_MULTIMODAL" default:"false"`
|
||||
|
||||
// Local is the optional third degradation level (裁决 03.4.3): primary →
|
||||
// fallback → local. It defaults to off because the current machine cannot run
|
||||
// a local model comfortably; when off the chain keeps its two-level shape.
|
||||
Local LocalLLMConfig
|
||||
|
||||
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
|
||||
// (primary → fallback). It is the final safety budget: it only applies when
|
||||
// the chain actually has a fallback step, that group sets no per-group
|
||||
// MaxTokens (models.json maxTokens) and the request carries no explicit
|
||||
// budget. Zero disables the cap (the adapter default then applies).
|
||||
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
|
||||
|
||||
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
|
||||
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
|
||||
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
|
||||
// the vendor from the model name/base URL and sends nothing when the shape is
|
||||
// ambiguous (the conservative default, so an existing .env is unchanged);
|
||||
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
|
||||
// explicitly. It is the env-fallback twin of the models.json group's
|
||||
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
|
||||
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
|
||||
|
||||
// Anthropic native provider, used when Provider=anthropic. Independent
|
||||
// config block so each vendor is a self-contained config.
|
||||
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
|
||||
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
|
||||
AnthropicModel string `env:"ANTHROPIC_MODEL"`
|
||||
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
|
||||
}
|
||||
|
||||
// LocalLLMConfig configures the optional local model rung — the third level of
|
||||
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
|
||||
// and defaults to off: a machine that cannot run a local model comfortably must
|
||||
// not silently pay for one. It becomes an active chain step only when Enabled is
|
||||
// true and both BaseURL and Model are set; anything less is reported and skipped
|
||||
// rather than producing a half-configured step.
|
||||
type LocalLLMConfig struct {
|
||||
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
|
||||
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
|
||||
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
|
||||
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
|
||||
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
|
||||
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
|
||||
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
|
||||
// APIKey is the local endpoint's key; empty is normal for a local server.
|
||||
APIKey string `env:"LLM_LOCAL_API_KEY"`
|
||||
// Model is the local model name. Without it the rung stays off.
|
||||
Model string `env:"LLM_LOCAL_MODEL"`
|
||||
}
|
||||
|
||||
// Configured reports whether the local rung has enough configuration to act as a
|
||||
// chain step. Enabled alone is not enough: a missing endpoint or model must not
|
||||
// become a step that can only fail.
|
||||
func (l LocalLLMConfig) Configured() bool {
|
||||
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
|
||||
}
|
||||
|
||||
// EmbeddingConfig holds the optional semantic embedding API settings. When
|
||||
// unset, the runtime falls back to built-in n-gram/FTS vectors.
|
||||
type EmbeddingConfig struct {
|
||||
@@ -466,6 +378,13 @@ func Load(envPath string) (*Config, error) {
|
||||
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 「未设置」与「显式 0」必须分开(见 ContextConfig.MaxTokensSet):反射解码只能
|
||||
// 给出值本身,因此这里补上「这个键到底有没有被配过」。
|
||||
cfg.Context.MaxTokensSet = envProvided(ContextMaxTokensEnv, fromEnv)
|
||||
if cfg.LLM.ContextWindow < 0 {
|
||||
return nil, fmt.Errorf("config: LLM_CONTEXT_WINDOW 不能为负(%d):0 = 未声明(按回落窗口推导)",
|
||||
cfg.LLM.ContextWindow)
|
||||
}
|
||||
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
|
||||
|
||||
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
|
||||
|
||||
+45
-15
@@ -1,23 +1,53 @@
|
||||
package config
|
||||
|
||||
// DefaultContextMaxTokens 是 CONTEXT_MAX_TOKENS 的默认值(32Ki token),与
|
||||
// loop.DefaultMaxContextTokens 取同一个数:项目早已把「上下文溢出压缩预算」
|
||||
// 定为 32K,默认值直接复用它,而不是另起一个数。两处的一致性由
|
||||
// cmd/cyrene/wire_context_test.go 锁定。
|
||||
const DefaultContextMaxTokens = 32768
|
||||
// 本文件承载「上下文预算」的配置语义(design/04 §1.3 上下文溢出压缩 +
|
||||
// 附录 A.3 的主人裁决)。推导本身在 context_budget.go。
|
||||
//
|
||||
// CONTEXT_MAX_TOKENS 是**三态**取值:
|
||||
//
|
||||
// - 未设置(默认) = 按模型窗口推导(DeriveContextBudget);
|
||||
// - > 0 = 显式覆盖(原样使用);
|
||||
// - 0 / 负数 = 关闭压缩(行为与引入推导之前逐字节一致)。
|
||||
//
|
||||
// 「未设置」不能用 0 表达:0 已经被「关闭压缩」占用。因此「有没有配过这个键」
|
||||
// 必须单独记录(ContextConfig.MaxTokensSet,由 Load 在解码阶段填),
|
||||
// 这正是 design/04 附录 A.3 第 2 条要求的「用 os.LookupEnv 判未设置,不要靠 0」。
|
||||
//
|
||||
// 旧口径(「默认模型 gpt-4o 窗口 128K ÷ 4 ⇒ 32K」)随本切片作废:默认模型的
|
||||
// 真实窗口已达 1M token(DevDocs/reference/deepseek/facts-for-cyrene.md),
|
||||
// 固定 32K 会既丢上下文、又因反复压缩作废 KV 缓存前缀而反复烧钱(附录 A.2)。
|
||||
|
||||
// ContextConfig 承载运行时循环的上下文预算(design/04 §1.3 上下文溢出压缩)。
|
||||
// ContextMaxTokensEnv 是上下文预算的配置键名。它必须与 ContextConfig.MaxTokens
|
||||
// 的 env 标签**逐字一致**(Load 用它判定「未设置」),一致性由
|
||||
// context_budget_test.go 的 TestContextMaxTokensEnvMatchesTag 钉住。
|
||||
const ContextMaxTokensEnv = "CONTEXT_MAX_TOKENS"
|
||||
|
||||
// ContextConfig 承载运行时循环的上下文预算(design/04 §1.3)。
|
||||
// 它与 MCP / Capacity 一样是运行时调优量,由组合根消费。
|
||||
type ContextConfig struct {
|
||||
// MaxTokens 是触发溢出压缩的上下文预算,单位是循环离线估算的 token 数
|
||||
// (CONTEXT_MAX_TOKENS)。**0 或负值 = 关闭压缩**,行为与本条接线前完全
|
||||
// 一致;未配置时用 DefaultContextMaxTokens(即默认启用)。
|
||||
// (CONTEXT_MAX_TOKENS)。三态语义见本文件顶部说明。
|
||||
//
|
||||
// 取值依据:默认模型(.env.example 的 gpt-4o)上下文窗口为 128K token,
|
||||
// 32K 只有它的四分之一 —— 既给系统提示词 / 工具 schema / 本轮输出留出
|
||||
// 充足余量(不会把窗口塞到上游静默截断或报错),又比日常对话的实际体量
|
||||
// 大一个量级(正常聊天几乎不触发压缩,宁可不压也不频繁压)。真触发时
|
||||
// compactor 仍保留 50% 预算的近期尾部(含尾部工具结果)并只把更早的轮次
|
||||
// 摘要成一条前导消息,因此不会打断正在进行的对话(design/04 §1.3)。
|
||||
MaxTokens int `env:"CONTEXT_MAX_TOKENS" default:"32768"`
|
||||
// **它刻意没有 default 标签**:未配置时值为 0,而「0 且未配置」与
|
||||
// 「显式 0」由 MaxTokensSet 区分 —— 前者按窗口推导,后者关闭压缩。
|
||||
MaxTokens int `env:"CONTEXT_MAX_TOKENS"`
|
||||
|
||||
// MaxTokensSet 报告 CONTEXT_MAX_TOKENS 是否由进程环境或 .env 文件给出。
|
||||
// 它由 Load 填写,不参与反射解码(没有 env 标签)。
|
||||
//
|
||||
// 判定规则(DeriveContextBudget 是唯一判据):MaxTokens > 0 一律按显式覆盖、
|
||||
// < 0 一律按关闭;只有 MaxTokens == 0 需要本标志区分「显式 0 = 关闭」与
|
||||
// 「未设置 = 推导」。因此手工构造 Config 的调用方给出非零值时不必同步本标志,
|
||||
// 但**要表达「显式 0 = 关闭」就必须置真**。
|
||||
MaxTokensSet bool
|
||||
|
||||
// DocumentMaxInjectChars 是**单份附件正文注入模型可见上下文**的字符上限
|
||||
// (DOCUMENT_MAX_INJECT_CHARS)。0(默认)= 按推导出的上下文预算算
|
||||
// (loop.DocumentInjectCharsFor);> 0 = 显式覆盖。
|
||||
//
|
||||
// 它与「sidecar 存多少」是两个数(design/24 §9.6 连带项、切片 T1 §7.2):
|
||||
// sidecar 存抽取得到的全文,只受安全上限 documents.MaxSidecarBytes 约束;
|
||||
// 本项只决定每回合往上下文里注入多少,截断因此发生在**装配期**,
|
||||
// 于是清单行的续读游标才真是「继续读的起点」。
|
||||
DocumentMaxInjectChars int `env:"DOCUMENT_MAX_INJECT_CHARS"`
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是上下文预算的**推导**(design/04 附录 A.3 的主人裁决):
|
||||
//
|
||||
// budget = min(窗口 × 比例, 窗口 − 输出保留)
|
||||
//
|
||||
// 它是一条纯函数,输入全部显式给出(窗口、该组 maxTokens、显式覆盖、比例),
|
||||
// 因此组合根只需把配置与生效模型组摊平喂进来,推导逻辑本身可表驱动测试。
|
||||
|
||||
const (
|
||||
// DefaultModelContextWindow 是模型窗口**未声明**时的回落窗口(128 Ki token,
|
||||
// design/04 附录 A.3 第 3 条)。回落而不是报错,是为了让没声明窗口的部署照常
|
||||
// 工作;但组合根会为它记**恰好一条** WARN,提示「声明 contextWindow /
|
||||
// LLM_CONTEXT_WINDOW 才能吃满窗口」—— 默认模型的真实窗口已达 1M token,
|
||||
// 回落值只是保守兜底。
|
||||
DefaultModelContextWindow = 131072
|
||||
|
||||
// DefaultContextBudgetRatio 是预算占窗口的默认比例 2/3。主人 2026-10-04 裁决
|
||||
// 「1/4 太小,1/2 到 3/4 之间吧」,本切片取区间内偏大的一侧:工具 schema、
|
||||
// 多轮工具结果与思考输出(缺省 64K、上限 384K)都要从窗口里分走一部分。
|
||||
// 可配:CONTEXT_BUDGET_RATIO。
|
||||
DefaultContextBudgetRatio = 2.0 / 3.0
|
||||
|
||||
// MinContextOutputReserve 是给**输出**留的最小 token 余量(design/04 附录 A.3
|
||||
// 第 3 条):官方明说输入 + 输出总长受窗口限制,而思考模式的缺省输出就有 64K,
|
||||
// 因此输入预算必须给输出让位。
|
||||
MinContextOutputReserve = 32768
|
||||
|
||||
// ContextBudgetRatioEnv 是推导比例的配置键名。
|
||||
ContextBudgetRatioEnv = "CONTEXT_BUDGET_RATIO"
|
||||
)
|
||||
|
||||
// ContextBudgetMode 说明一个预算数是「怎么来的」。它只用于日志与断言,
|
||||
// 不参与任何行为分支之外的判定。
|
||||
type ContextBudgetMode string
|
||||
|
||||
const (
|
||||
// ContextBudgetDerived 表示按窗口推导得到。
|
||||
ContextBudgetDerived ContextBudgetMode = "derived"
|
||||
// ContextBudgetOverride 表示 CONTEXT_MAX_TOKENS 显式覆盖。
|
||||
ContextBudgetOverride ContextBudgetMode = "override"
|
||||
// ContextBudgetOff 表示显式关闭压缩(0 / 负数)。
|
||||
ContextBudgetOff ContextBudgetMode = "off"
|
||||
)
|
||||
|
||||
// ContextBudgetInput 是一次推导的全部输入。字段都是值,没有任何隐式来源:
|
||||
// 「这个数从哪来」由组合根一处决定(cmd/cyrene/wire_context.go)。
|
||||
type ContextBudgetInput struct {
|
||||
// Window 是声明的模型窗口(token)。0 = 未声明(回落到
|
||||
// DefaultModelContextWindow 并提示)。
|
||||
Window int
|
||||
// GroupMaxTokens 是生效模型组声明的 maxTokens(models.json)。它参与
|
||||
// outputReserve 的计算;0 = 该组未声明。
|
||||
GroupMaxTokens int
|
||||
// Override 是 CONTEXT_MAX_TOKENS 的取值。
|
||||
Override int
|
||||
// OverrideConfigured 报告 Override 是否被显式配置(见 ContextConfig.MaxTokensSet)。
|
||||
OverrideConfigured bool
|
||||
// Ratio 是推导比例。非正数或大于 1 视为非法,回落到
|
||||
// DefaultContextBudgetRatio(非法值的报告由调用方负责,见
|
||||
// LoadContextBudgetRatio:那里才知道原始文本,能记出「配了什么」)。
|
||||
Ratio float64
|
||||
}
|
||||
|
||||
// ContextBudget 是一次推导的结论。Tokens 是循环真正用的预算;
|
||||
// 其余字段是给日志与断言的可追溯信息。
|
||||
type ContextBudget struct {
|
||||
// Tokens 是最终预算:> 0 表示启用压缩;<= 0 表示关闭压缩。
|
||||
Tokens int
|
||||
// Mode 说明这个数怎么来的。
|
||||
Mode ContextBudgetMode
|
||||
// Window 是本次实际采用的窗口(未声明时为回落值)。
|
||||
Window int
|
||||
// WindowDeclared 报告窗口是否来自声明。为 false 且 Mode == derived 时,
|
||||
// 组合根记一条 WARN(提示声明窗口才能吃满)—— 这是「恰好一条」的那条。
|
||||
WindowDeclared bool
|
||||
// Ratio 是本次实际采用的比例(已回落)。
|
||||
Ratio float64
|
||||
// OutputReserve 是本次采用的输出保留量。
|
||||
OutputReserve int
|
||||
}
|
||||
|
||||
// DeriveContextBudget 按 design/04 附录 A.3 推导上下文预算。
|
||||
//
|
||||
// 取值优先级:显式覆盖 > 关闭 > 推导。具体地 ——
|
||||
//
|
||||
// - Override > 0:原样返回(override);
|
||||
// - Override < 0,或 Override == 0 且 OverrideConfigured:返回该值(off,
|
||||
// 0 与负数都表示关闭压缩,与接线前一致,**不推导**);
|
||||
// - 其余(即没配过):按窗口推导。
|
||||
//
|
||||
// 推导式 budget = min(window × ratio, window − outputReserve):
|
||||
// 前者给「不能把窗口塞满」留比例余量,后者给输出留绝对余量。
|
||||
// 输出保留取 max(MinContextOutputReserve, 该组 maxTokens),并且**不超过窗口的
|
||||
// 一半** —— 组把 maxTokens 声明得比窗口还大时(配置自相矛盾),也不能让
|
||||
// 「窗口 − 输出」退化到 0 或负数,否则预算会变成一个荒唐的极小值。
|
||||
func DeriveContextBudget(in ContextBudgetInput) ContextBudget {
|
||||
if in.Override > 0 {
|
||||
return ContextBudget{Tokens: in.Override, Mode: ContextBudgetOverride}
|
||||
}
|
||||
if in.Override < 0 || in.OverrideConfigured {
|
||||
return ContextBudget{Tokens: in.Override, Mode: ContextBudgetOff}
|
||||
}
|
||||
|
||||
window := in.Window
|
||||
declared := window > 0
|
||||
if !declared {
|
||||
window = DefaultModelContextWindow
|
||||
}
|
||||
ratio := in.Ratio
|
||||
if ratio <= 0 || ratio > 1 {
|
||||
ratio = DefaultContextBudgetRatio
|
||||
}
|
||||
reserve := ContextOutputReserve(in.GroupMaxTokens)
|
||||
if half := window / 2; reserve > half {
|
||||
reserve = half
|
||||
}
|
||||
tokens := int(float64(window) * ratio)
|
||||
if limit := window - reserve; tokens > limit {
|
||||
tokens = limit
|
||||
}
|
||||
if tokens < 1 {
|
||||
tokens = 1
|
||||
}
|
||||
return ContextBudget{
|
||||
Tokens: tokens,
|
||||
Mode: ContextBudgetDerived,
|
||||
Window: window,
|
||||
WindowDeclared: declared,
|
||||
Ratio: ratio,
|
||||
OutputReserve: reserve,
|
||||
}
|
||||
}
|
||||
|
||||
// ContextOutputReserve 返回给输出留的 token 余量:max(MinContextOutputReserve,
|
||||
// groupMaxTokens)。组声明了更大的输出预算时以它为准(思考模式上限 384K)。
|
||||
func ContextOutputReserve(groupMaxTokens int) int {
|
||||
if groupMaxTokens > MinContextOutputReserve {
|
||||
return groupMaxTokens
|
||||
}
|
||||
return MinContextOutputReserve
|
||||
}
|
||||
|
||||
// LoadContextBudgetRatio 读取推导比例(CONTEXT_BUDGET_RATIO),优先级与其余配置
|
||||
// 完全一致:进程环境 > .env > 默认。未配置时返回 DefaultContextBudgetRatio。
|
||||
//
|
||||
// 为什么需要读取函数而不是给 Config 加一个 float 字段:Config 的反射解码器只做
|
||||
// 标量赋值,承载不了「非法值必须被报告并回落」的语义(记 WARN 需要原始文本);
|
||||
// 而直接 os.Getenv 会让只写在 .env 里的声明被静默忽略(.env 由 parseDotEnv 读进
|
||||
// 独立 map,并不写回进程环境)。这里复用同一套优先级与同一份 .env 解析,因此两种
|
||||
// 写法都真实生效 —— 与 LoadOwnerIdentityInjection 同源。envPath 为空表示不看
|
||||
// .env(与 Load 的约定一致)。
|
||||
//
|
||||
// 返回的 error 只承载「配了但读不懂」:调用方据此记一条带原始取值的 WARN 并回落
|
||||
// 默认比例,绝不静默带过(AGENTS §10:非法配置不许静默失效)。
|
||||
func LoadContextBudgetRatio(envPath string) (float64, error) {
|
||||
fromEnv := map[string]string{}
|
||||
if envPath != "" {
|
||||
m, err := parseDotEnv(envPath)
|
||||
if err != nil {
|
||||
return DefaultContextBudgetRatio, fmt.Errorf("config: read %s: %w", envPath, err)
|
||||
}
|
||||
fromEnv = m
|
||||
}
|
||||
raw := strings.TrimSpace(lookupRaw(ContextBudgetRatioEnv, "", fromEnv))
|
||||
if raw == "" {
|
||||
return DefaultContextBudgetRatio, nil
|
||||
}
|
||||
ratio, err := ParseContextBudgetRatio(raw)
|
||||
if err != nil {
|
||||
return DefaultContextBudgetRatio, err
|
||||
}
|
||||
return ratio, nil
|
||||
}
|
||||
|
||||
// ParseContextBudgetRatio 把比例文本解析成 (0, 1] 内的浮点数。两种写法都接受:
|
||||
// 小数(`0.6667`)与分数(`2/3`)—— 设计文档与主人裁决都用分数表述,若只认小数,
|
||||
// 写成 `2/3` 的部署会得到一条 WARN 并被静默回落到默认值,那正是「配了当没配」。
|
||||
func ParseContextBudgetRatio(raw string) (float64, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
value, err := parseRatioValue(text)
|
||||
if err != nil {
|
||||
return DefaultContextBudgetRatio, err
|
||||
}
|
||||
if value <= 0 || value > 1 {
|
||||
return DefaultContextBudgetRatio, fmt.Errorf("config: %s=%q 超出可取范围 (0, 1]",
|
||||
ContextBudgetRatioEnv, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseRatioValue 解析单个比例文本,失败时给出带原始取值的错误。
|
||||
func parseRatioValue(text string) (float64, error) {
|
||||
badRatio := func() error {
|
||||
return fmt.Errorf("config: %s=%q 不是合法比例(用 0.6667 或 2/3 这类写法)",
|
||||
ContextBudgetRatioEnv, text)
|
||||
}
|
||||
if num, den, ok := strings.Cut(text, "/"); ok {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(num), 64)
|
||||
if err != nil {
|
||||
return 0, badRatio()
|
||||
}
|
||||
d, err := strconv.ParseFloat(strings.TrimSpace(den), 64)
|
||||
if err != nil || d == 0 {
|
||||
return 0, badRatio()
|
||||
}
|
||||
return n / d, nil
|
||||
}
|
||||
n, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil {
|
||||
return 0, badRatio()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住上下文预算的推导(design/04 附录 A.3):budget = min(窗口 × 比例,
|
||||
// 窗口 − 输出保留),以及 CONTEXT_BUDGET_RATIO 的解析与回落口径。
|
||||
|
||||
// 默认模型的真实窗口(facts-for-cyrene.md)与两条便于阅读的取值。
|
||||
const (
|
||||
oneMillionWindow = 1048576
|
||||
maxTokens384K = 384 * 1024
|
||||
)
|
||||
|
||||
// budgetCase 是推导用例的一条断言:输入 + 期望的结论要点。
|
||||
type budgetCase struct {
|
||||
name string
|
||||
in ContextBudgetInput
|
||||
wantTokens int
|
||||
wantMode ContextBudgetMode
|
||||
wantWindow int
|
||||
wantDeclared bool
|
||||
wantReserve int
|
||||
}
|
||||
|
||||
// TestDeriveContextBudget_Derived 钉住推导侧的五种情形(任务书 §4 必测 1 的
|
||||
// ①②③,外加比例回落与输出保留封顶)。
|
||||
func TestDeriveContextBudget_Derived(t *testing.T) {
|
||||
t.Parallel()
|
||||
ratio := DefaultContextBudgetRatio
|
||||
cases := []budgetCase{
|
||||
{
|
||||
// ① 1M × 2/3 = 699050 token(文档里写作「683K」,那是 Ki:699050/1024 ≈ 683)。
|
||||
name: "1M 窗口按 2/3 推导",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, Ratio: ratio},
|
||||
wantTokens: 699050,
|
||||
wantMode: ContextBudgetDerived,
|
||||
wantWindow: oneMillionWindow,
|
||||
wantDeclared: true,
|
||||
wantReserve: MinContextOutputReserve,
|
||||
},
|
||||
{
|
||||
// ② 该组 maxTokens=384K ⇒ 输出保留顶到 384K ⇒ min(699050, 1M−384K=655360)。
|
||||
name: "带 384K 输出保留时取窗口减输出的那一侧",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, GroupMaxTokens: maxTokens384K, Ratio: ratio},
|
||||
wantTokens: 655360,
|
||||
wantMode: ContextBudgetDerived,
|
||||
wantWindow: oneMillionWindow,
|
||||
wantDeclared: true,
|
||||
wantReserve: maxTokens384K,
|
||||
},
|
||||
{
|
||||
// ③ 未声明窗口 ⇒ 回落 131072,131072 × 2/3 = 87381(截尾),
|
||||
// 且 WindowDeclared=false(组合根据此记恰好一条 WARN)。
|
||||
name: "未声明窗口回落 128Ki 并提示",
|
||||
in: ContextBudgetInput{Ratio: ratio},
|
||||
wantTokens: 87381,
|
||||
wantMode: ContextBudgetDerived,
|
||||
wantWindow: DefaultModelContextWindow,
|
||||
wantDeclared: false,
|
||||
wantReserve: MinContextOutputReserve,
|
||||
},
|
||||
{
|
||||
name: "非法比例回落到 2/3",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, Ratio: 3},
|
||||
wantTokens: 699050,
|
||||
wantMode: ContextBudgetDerived,
|
||||
wantWindow: oneMillionWindow,
|
||||
wantDeclared: true,
|
||||
wantReserve: MinContextOutputReserve,
|
||||
},
|
||||
{
|
||||
// maxTokens 被声明得比窗口还大(配置矛盾)时,输出保留封顶在窗口一半,
|
||||
// 预算绝不退化成 0 或负数。
|
||||
name: "输出保留不超过窗口一半",
|
||||
in: ContextBudgetInput{Window: 32768, GroupMaxTokens: maxTokens384K, Ratio: ratio},
|
||||
wantTokens: 16384,
|
||||
wantMode: ContextBudgetDerived,
|
||||
wantWindow: 32768,
|
||||
wantDeclared: true,
|
||||
wantReserve: 16384,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assertBudgetCase(t, c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveContextBudget_OverrideAndOff 钉住显式覆盖与关闭(任务书 §4 必测 1 的
|
||||
// ④⑤):两者都**不推导**,窗口声明与否都不影响结论。
|
||||
func TestDeriveContextBudget_OverrideAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
ratio := DefaultContextBudgetRatio
|
||||
cases := []budgetCase{
|
||||
{
|
||||
name: "显式覆盖原样使用",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, Override: 4096, OverrideConfigured: true, Ratio: ratio},
|
||||
wantTokens: 4096,
|
||||
wantMode: ContextBudgetOverride,
|
||||
},
|
||||
{
|
||||
name: "非零覆盖不必同步标志位也生效",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, Override: 600, Ratio: ratio},
|
||||
wantTokens: 600,
|
||||
wantMode: ContextBudgetOverride,
|
||||
},
|
||||
{
|
||||
name: "显式 0 = 关闭压缩(不推导)",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, Override: 0, OverrideConfigured: true, Ratio: ratio},
|
||||
wantTokens: 0,
|
||||
wantMode: ContextBudgetOff,
|
||||
},
|
||||
{
|
||||
name: "负值 = 关闭压缩(不推导)",
|
||||
in: ContextBudgetInput{Window: oneMillionWindow, Override: -1, OverrideConfigured: true, Ratio: ratio},
|
||||
wantTokens: -1,
|
||||
wantMode: ContextBudgetOff,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := DeriveContextBudget(c.in)
|
||||
if got.Tokens != c.wantTokens || got.Mode != c.wantMode {
|
||||
t.Fatalf("DeriveContextBudget = {tokens:%d mode:%s}, want {tokens:%d mode:%s}",
|
||||
got.Tokens, got.Mode, c.wantTokens, c.wantMode)
|
||||
}
|
||||
if got.WindowDeclared || got.Window != 0 || got.OutputReserve != 0 {
|
||||
t.Fatalf("覆盖/关闭态不该带推导结论:%+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertBudgetCase 校验一条推导用例:结论要点 + 推导侧特有的窗口与保留量。
|
||||
func assertBudgetCase(t *testing.T, c budgetCase) {
|
||||
t.Helper()
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := DeriveContextBudget(c.in)
|
||||
if got.Tokens != c.wantTokens || got.Mode != c.wantMode {
|
||||
t.Fatalf("DeriveContextBudget = {tokens:%d mode:%s}, want {tokens:%d mode:%s}",
|
||||
got.Tokens, got.Mode, c.wantTokens, c.wantMode)
|
||||
}
|
||||
if got.WindowDeclared != c.wantDeclared {
|
||||
t.Fatalf("WindowDeclared = %v, want %v", got.WindowDeclared, c.wantDeclared)
|
||||
}
|
||||
if got.Window != c.wantWindow || got.OutputReserve != c.wantReserve {
|
||||
t.Fatalf("window/reserve = %d/%d, want %d/%d", got.Window, got.OutputReserve, c.wantWindow, c.wantReserve)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeriveContextBudget_OneMillionWindowBeatsTheOld32K 是主人裁决的直接体现:
|
||||
// 1M 窗口下推导出的预算必须是旧默认 32K 的二十倍以上 —— 否则「既丢上下文、又反复
|
||||
// 烧缓存」(design/04 附录 A.2 第 2 条)的老毛病没被治好。
|
||||
func TestDeriveContextBudget_OneMillionWindowBeatsTheOld32K(t *testing.T) {
|
||||
t.Parallel()
|
||||
const oldDefault = 32768
|
||||
got := DeriveContextBudget(ContextBudgetInput{Window: oneMillionWindow})
|
||||
if got.Tokens <= oldDefault*20 {
|
||||
t.Fatalf("1M 窗口推导出的预算 = %d,仍贴着旧默认 %d(推导没生效)", got.Tokens, oldDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseContextBudgetRatio 钉住比例的两种写法与非法值判定(非法值由调用方记
|
||||
// WARN 并回落默认,绝不静默生效)。
|
||||
func TestParseContextBudgetRatio(t *testing.T) {
|
||||
t.Parallel()
|
||||
ok := []struct {
|
||||
raw string
|
||||
want float64
|
||||
}{
|
||||
{"0.5", 0.5},
|
||||
{"0.6667", 0.6667},
|
||||
{" 2/3 ", 2.0 / 3.0},
|
||||
{"3/4", 0.75},
|
||||
{"1", 1},
|
||||
}
|
||||
for _, c := range ok {
|
||||
got, err := ParseContextBudgetRatio(c.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseContextBudgetRatio(%q) 报错: %v", c.raw, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("ParseContextBudgetRatio(%q) = %v, want %v", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"maybe", "0", "-0.5", "1.5", "2/0", "/3", "3/"} {
|
||||
if _, err := ParseContextBudgetRatio(raw); err == nil {
|
||||
t.Fatalf("ParseContextBudgetRatio(%q) 竟然通过了 —— 非法比例必须被报告,不得静默生效", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadContextBudgetRatio 钉住读取链:进程环境 > .env > 默认,且非法值返回
|
||||
// 错误(调用方据此记 WARN 回落默认,并带上原始取值)。
|
||||
func TestLoadContextBudgetRatio(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envFile := filepath.Join(dir, "test.env")
|
||||
if err := os.WriteFile(envFile, []byte(ContextBudgetRatioEnv+"=0.5\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
|
||||
unsetenvForTest(t, ContextBudgetRatioEnv)
|
||||
got, err := LoadContextBudgetRatio(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("读 .env 里的比例: %v", err)
|
||||
}
|
||||
if got != 0.5 {
|
||||
t.Fatalf(".env 里的比例 = %v, want 0.5", got)
|
||||
}
|
||||
|
||||
// 进程环境优先于 .env。
|
||||
t.Setenv(ContextBudgetRatioEnv, "2/3")
|
||||
got, err = LoadContextBudgetRatio(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("读进程环境里的比例: %v", err)
|
||||
}
|
||||
if got != 2.0/3.0 {
|
||||
t.Fatalf("进程环境里的比例 = %v, want 2/3", got)
|
||||
}
|
||||
|
||||
// 非法值:返回错误,并回落到默认(错误里带原始取值,便于 WARN)。
|
||||
t.Setenv(ContextBudgetRatioEnv, "四分之三")
|
||||
got, err = LoadContextBudgetRatio(envFile)
|
||||
if err == nil {
|
||||
t.Fatal("非法比例必须返回错误")
|
||||
}
|
||||
if got != DefaultContextBudgetRatio {
|
||||
t.Fatalf("非法比例的回落 = %v, want %v", got, DefaultContextBudgetRatio)
|
||||
}
|
||||
|
||||
// 未配置(.env 也不看):默认比例,不报错。
|
||||
t.Setenv(ContextBudgetRatioEnv, "")
|
||||
got, err = LoadContextBudgetRatio(filepath.Join(dir, "missing.env"))
|
||||
if err != nil || got != DefaultContextBudgetRatio {
|
||||
t.Fatalf("未配置比例 = (%v, %v), want (%v, nil)", got, err, DefaultContextBudgetRatio)
|
||||
}
|
||||
}
|
||||
|
||||
// unsetenvForTest 清掉一个环境变量并在测试结束时还原(t.Setenv 只能设值,而空串
|
||||
// 会被解析成 0 / 空串,与「未设置」不是一回事 —— 本文件恰好要区分这两者)。
|
||||
func unsetenvForTest(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
prev, had := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("Unsetenv(%s): %v", key, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, prev)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(key)
|
||||
})
|
||||
}
|
||||
|
||||
// TestContextMaxTokensEnvMatchesTag 钉住 ContextMaxTokensEnv 与字段的 env 标签
|
||||
// 逐字一致:Load 用前者判定「未设置」,标签决定反射解码读哪个变量;两者一旦漂移,
|
||||
// 「未设置 ⇒ 按窗口推导」就会在真实进程里静默失效(而单测仍可能全绿)。
|
||||
func TestContextMaxTokensEnvMatchesTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
field, ok := reflect.TypeOf(ContextConfig{}).FieldByName("MaxTokens")
|
||||
if !ok {
|
||||
t.Fatal("ContextConfig 里没有 MaxTokens 字段")
|
||||
}
|
||||
if got := field.Tag.Get("env"); got != ContextMaxTokensEnv {
|
||||
t.Fatalf("MaxTokens 的 env 标签 = %q, want %q", got, ContextMaxTokensEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_ContextMaxTokensTriState 钉住三态判定的来源:只有真的配过这个键,
|
||||
// MaxTokensSet 才为真 —— 反射解码只能给出值,分不出「没配」与「配了 0」。
|
||||
func TestLoad_ContextMaxTokensTriState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
|
||||
unsetenvForTest(t, ContextMaxTokensEnv)
|
||||
|
||||
cfg, err := Load(filepath.Join(dir, "absent.env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Context.MaxTokens != 0 || cfg.Context.MaxTokensSet {
|
||||
t.Fatalf("未配置时 = (%d, set=%v), want (0, false)(未设置 ⇒ 按窗口推导)",
|
||||
cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
|
||||
}
|
||||
|
||||
t.Setenv(ContextMaxTokensEnv, "0")
|
||||
cfg, err = Load(filepath.Join(dir, "absent.env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Context.MaxTokens != 0 || !cfg.Context.MaxTokensSet {
|
||||
t.Fatalf("显式 0 时 = (%d, set=%v), want (0, true)(显式 0 ⇒ 关闭压缩)",
|
||||
cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
|
||||
}
|
||||
|
||||
// .env 里写了也算「配过」(与进程环境同一优先级链)。
|
||||
envFile := filepath.Join(dir, "test.env")
|
||||
if err := os.WriteFile(envFile, []byte(ContextMaxTokensEnv+"=2048\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
unsetenvForTest(t, ContextMaxTokensEnv)
|
||||
cfg, err = Load(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Context.MaxTokens != 2048 || !cfg.Context.MaxTokensSet {
|
||||
t.Fatalf(".env 配置时 = (%d, set=%v), want (2048, true)", cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_RejectsNegativeContextWindow 钉住窗口的负数校验:窗口没有负数语义,
|
||||
// 静默当成「未声明」会让一个配错的部署悄悄用回落窗口而非报错。
|
||||
func TestLoad_RejectsNegativeContextWindow(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
|
||||
t.Setenv("LLM_CONTEXT_WINDOW", "-1")
|
||||
if _, err := Load(filepath.Join(t.TempDir(), "absent.env")); err == nil {
|
||||
t.Fatal("负数窗口必须让 Load 报错")
|
||||
}
|
||||
t.Setenv("LLM_CONTEXT_WINDOW", "1048576")
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.LLM.ContextWindow != 1048576 {
|
||||
t.Fatalf("LLM_CONTEXT_WINDOW = %d, want 1048576", cfg.LLM.ContextWindow)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,20 @@ func lookupRaw(key, def string, fromEnv map[string]string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// envProvided reports whether key was given by the process environment or the
|
||||
// .env file — the same precedence chain as lookupRaw, only separating out the
|
||||
// "fell through to the default tag" case. It carries semantics that need to
|
||||
// tell "unset" from "explicitly zero" (CONTEXT_MAX_TOKENS: 0 = compaction off,
|
||||
// unset = derive the budget from the model window), which a plain zero value
|
||||
// cannot express.
|
||||
func envProvided(key string, fromEnv map[string]string) bool {
|
||||
if _, ok := os.LookupEnv(key); ok {
|
||||
return true
|
||||
}
|
||||
_, ok := fromEnv[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func setField(fv reflect.Value, raw string) error {
|
||||
switch fv.Kind() {
|
||||
case reflect.String:
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
// 本文件承载多供应商 LLM 与其本地第三级的配置块(design/04 §3.1;裁决 03.4.3)。
|
||||
// 它从 config.go 拆出来有两条理由:config.go 已贴着 AGENTS §10 的 500 行硬上限,
|
||||
// 而「LLM 各供应商各自的端点/密钥/模型/推理形状」本来就是一块自洽的配置面。
|
||||
|
||||
import "strings"
|
||||
|
||||
// LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
|
||||
// wire protocol; every provider has its own independent base/key/model/fallback
|
||||
// so multiple vendors can coexist.
|
||||
type LLMConfig struct {
|
||||
// Provider declares which protocol to use: "openai" (OpenAI-compatible
|
||||
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
|
||||
Provider string `env:"LLM_PROVIDER" default:"openai"`
|
||||
|
||||
// OpenAI-compatible provider (default). Kept flat for backward compatibility
|
||||
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
|
||||
BaseURL string `env:"LLM_BASE_URL"`
|
||||
APIKey string `env:"LLM_API_KEY"`
|
||||
Model string `env:"LLM_MODEL"`
|
||||
Fallback string `env:"LLM_FALLBACK_MODEL"`
|
||||
|
||||
// VisionModel is the dedicated model behind the vision tool (image
|
||||
// understanding). It is the tool's model, not the main model's ability: the
|
||||
// per-persona/per-request routing decision belongs to the vision Router, and
|
||||
// the main model's own image input capability is declared by the active
|
||||
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
|
||||
VisionModel string `env:"LLM_VISION_MODEL"`
|
||||
|
||||
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
|
||||
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
|
||||
// 的「独立视觉模型 + OCR 模型」)。
|
||||
OCRModel string `env:"LLM_OCR_MODEL"`
|
||||
|
||||
// Multimodal 声明**主模型自身**能否收图:models.json 组 `multimodal` 的 env
|
||||
// 等价物,只作用于 .env 合成的组,默认 false(语义与分工见 GroupFromEnv)。
|
||||
Multimodal bool `env:"LLM_MULTIMODAL" default:"false"`
|
||||
|
||||
// ContextWindow 声明该端点的**模型上下文窗口**(token,LLM_CONTEXT_WINDOW)。
|
||||
// 0(默认)表示未声明,此时上下文预算推导回落到
|
||||
// config.DefaultModelContextWindow 并记一条 WARN(design/04 附录 A.3)。
|
||||
// 负数在 Load 阶段直接报错(窗口没有负数语义)。
|
||||
//
|
||||
// 它是 models.json 组 `contextWindow` 的 env 等价物(per-group 播种,语义见
|
||||
// runtimeconfig.Materialize);本仓目前没有任何 provider 侧窗口发现代码,
|
||||
// 因此窗口是**声明式**的: `GET /models` 的自动发现另立设计。
|
||||
ContextWindow int `env:"LLM_CONTEXT_WINDOW"`
|
||||
|
||||
// Local is the optional third degradation level (裁决 03.4.3): primary →
|
||||
// fallback → local. It defaults to off because the current machine cannot run
|
||||
// a local model comfortably; when off the chain keeps its two-level shape.
|
||||
Local LocalLLMConfig
|
||||
|
||||
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
|
||||
// (primary → fallback). It is the final safety budget: it only applies when
|
||||
// the chain actually has a fallback step, that group sets no per-group
|
||||
// MaxTokens (models.json maxTokens) and the request carries no explicit
|
||||
// budget. Zero disables the cap (the adapter default then applies).
|
||||
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
|
||||
|
||||
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
|
||||
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
|
||||
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
|
||||
// the vendor from the model name/base URL and sends nothing when the shape is
|
||||
// ambiguous (the conservative default, so an existing .env is unchanged);
|
||||
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
|
||||
// explicitly. It is the env-fallback twin of the models.json group's
|
||||
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
|
||||
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
|
||||
|
||||
// Anthropic native provider, used when Provider=anthropic. Independent
|
||||
// config block so each vendor is a self-contained config.
|
||||
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
|
||||
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
|
||||
AnthropicModel string `env:"ANTHROPIC_MODEL"`
|
||||
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
|
||||
}
|
||||
|
||||
// LocalLLMConfig configures the optional local model rung — the third level of
|
||||
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
|
||||
// and defaults to off: a machine that cannot run a local model comfortably must
|
||||
// not silently pay for one. It becomes an active chain step only when Enabled is
|
||||
// true and both BaseURL and Model are set; anything less is reported and skipped
|
||||
// rather than producing a half-configured step.
|
||||
type LocalLLMConfig struct {
|
||||
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
|
||||
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
|
||||
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
|
||||
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
|
||||
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
|
||||
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
|
||||
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
|
||||
// APIKey is the local endpoint's key; empty is normal for a local server.
|
||||
APIKey string `env:"LLM_LOCAL_API_KEY"`
|
||||
// Model is the local model name. Without it the rung stays off.
|
||||
Model string `env:"LLM_LOCAL_MODEL"`
|
||||
}
|
||||
|
||||
// Configured reports whether the local rung has enough configuration to act as a
|
||||
// chain step. Enabled alone is not enough: a missing endpoint or model must not
|
||||
// become a step that can only fail.
|
||||
func (l LocalLLMConfig) Configured() bool {
|
||||
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件钉住组级 `contextWindow`(design/04 附录 A.3 第 1 条:声明式窗口)。
|
||||
// 窗口是上下文预算推导的输入,因此它必须:能校验、能随 env 播种、能按既有
|
||||
// materialize 语义(面板优先、只补缺失键)落进面板配置。
|
||||
|
||||
// TestGroupValidate_RejectsNegativeContextWindow 钉住负数窗口在校验阶段报错:
|
||||
// 窗口没有负数语义,静默当成「未声明」会让一个配错的部署悄悄用回落窗口。
|
||||
func TestGroupValidate_RejectsNegativeContextWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := Group{ID: "g", Provider: ProviderOpenAI, BaseURL: "https://x/v1", Model: "m"}
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Fatalf("未声明窗口(0)应当合法: %v", err)
|
||||
}
|
||||
declared := base
|
||||
declared.ContextWindow = 1048576
|
||||
if err := declared.Validate(); err != nil {
|
||||
t.Fatalf("正数窗口应当合法: %v", err)
|
||||
}
|
||||
negative := base
|
||||
negative.ContextWindow = -1
|
||||
err := negative.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("负数窗口必须报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "contextWindow") {
|
||||
t.Fatalf("错误信息没点出字段名: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupFromEnv_CarriesContextWindow 钉住 .env 合成的组带上 LLM_CONTEXT_WINDOW,
|
||||
// 否则「本仓没有 provider 侧窗口发现代码」的部署永远只能吃回落窗口。
|
||||
func TestGroupFromEnv_CarriesContextWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, provider := range []string{ProviderOpenAI, ProviderAnthropic} {
|
||||
env := config.LLMConfig{Provider: provider, ContextWindow: 1048576}
|
||||
got := GroupFromEnv(env)[0]
|
||||
if got.ContextWindow != 1048576 {
|
||||
t.Fatalf("provider=%s: ContextWindow = %d, want 1048576", provider, got.ContextWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaterializeFillsContextWindowFromEnv 钉住 per-group 播种:面板缺该键 ⇒ 由
|
||||
// LLM_CONTEXT_WINDOW 补上并落盘;面板已有该键 ⇒ 面板优先(与其余键同一规则)。
|
||||
func TestMaterializeFillsContextWindowFromEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv()
|
||||
env.ContextWindow = 1048576
|
||||
|
||||
// 缺失 ⇒ 补齐。
|
||||
path := writeModelsFile(t, `[
|
||||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model"}
|
||||
]`)
|
||||
res, err := Materialize(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("Materialize: %v", err)
|
||||
}
|
||||
if got, want := fillStrings(res.Filled), []string{"openai.contextWindow"}; !equalStrings(got, want) {
|
||||
t.Fatalf("Filled = %v, want %v", got, want)
|
||||
}
|
||||
if res.Groups[0].ContextWindow != 1048576 {
|
||||
t.Fatalf("补齐后的窗口 = %d, want 1048576", res.Groups[0].ContextWindow)
|
||||
}
|
||||
|
||||
// 面板已有 ⇒ 不覆盖,且一个字节不动。
|
||||
declared := writeModelsFile(t, `[
|
||||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model","contextWindow":32768}
|
||||
]`)
|
||||
before := readRaw(t, declared)
|
||||
res2, err := Materialize(declared, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("Materialize: %v", err)
|
||||
}
|
||||
if res2.Groups[0].ContextWindow != 32768 {
|
||||
t.Fatalf("面板声明的窗口被 env 覆盖:%d", res2.Groups[0].ContextWindow)
|
||||
}
|
||||
if res2.Written || readRaw(t, declared) != before {
|
||||
t.Fatal("面板已有该键时不得写文件")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaterializeNeverFillsZeroContextWindow 钉住「0 不是可补的默认值」:env 未声明
|
||||
// 窗口(0)时绝不写一个 0 进面板配置 —— 键的缺席才是「未声明」的唯一形态
|
||||
// (omitempty),写进去只会制造 schema 噪声。
|
||||
func TestMaterializeNeverFillsZeroContextWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv() // ContextWindow 零值
|
||||
path := writeModelsFile(t, `[
|
||||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model"}
|
||||
]`)
|
||||
res, err := Materialize(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("Materialize: %v", err)
|
||||
}
|
||||
if len(res.Filled) != 0 {
|
||||
t.Fatalf("Filled = %v, want 空(env 没有声明窗口)", fillStrings(res.Filled))
|
||||
}
|
||||
if strings.Contains(readRaw(t, path), "contextWindow") {
|
||||
t.Fatal("env 未声明窗口却把该键写进了面板配置")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
@@ -40,11 +41,17 @@ type fillSpec struct {
|
||||
|
||||
// groupFillSpecs 是 `.env` 默认层覆盖的键全集,顺序即日志顺序。
|
||||
//
|
||||
// 为什么是这四个键:`.env` 侧确实有对应变量(LLM_PROVIDER / LLM_BASE_URL /
|
||||
// 为什么是这几个键:`.env` 侧确实有对应变量(LLM_PROVIDER / LLM_BASE_URL /
|
||||
// LLM_API_KEY / LLM_MODEL 及其 ANTHROPIC_* 对偶),也就是 GroupFromEnv 从 `.env`
|
||||
// LLM 块写进合成组的那几个字段。`id` / `maxTokens` / `personaModels` /
|
||||
// `personaProviders` 在 `.env` 里没有出处,因此永远不由 env 补。
|
||||
//
|
||||
// `contextWindow` 是唯一被纳入的**整型**键(LLM_CONTEXT_WINDOW 的 per-group 播种,
|
||||
// design/04 附录 A.3 第 1 条)。它用十进制字符串承载,但 0 不构成一个可补的默认值
|
||||
// —— models.json 里 0 与「键被省略」是同一个字节形态(omitempty),
|
||||
// 因此 env 只补正数窗口,绝不让「面板里清掉窗口 → 重启被 env 静默补回」成立
|
||||
// (与下面 fallback/multimodal/reasoning.kind 被排除的理由同源)。
|
||||
//
|
||||
// `fallback` / `multimodal` / `reasoning.kind` 刻意**不在表内**:它们带
|
||||
// omitempty / omitzero,在 models.json 里「面板清空该键」与「从未写过该键」是同一个
|
||||
// 字节形态(键直接不写)。把它们纳入补齐,「面板里清掉 → 重启被 `.env` 静默补回」
|
||||
@@ -55,6 +62,27 @@ var groupFillSpecs = []fillSpec{
|
||||
{key: "baseUrl", get: func(g Group) string { return g.BaseURL }, set: func(g *Group, v string) { g.BaseURL = v }},
|
||||
{key: "apiKey", get: func(g Group) string { return g.APIKey }, set: func(g *Group, v string) { g.APIKey = v }},
|
||||
{key: "model", get: func(g Group) string { return g.Model }, set: func(g *Group, v string) { g.Model = v }},
|
||||
{key: "contextWindow", get: func(g Group) string { return positiveIntText(g.ContextWindow) },
|
||||
set: func(g *Group, v string) { g.ContextWindow = parsePositiveInt(v) }},
|
||||
}
|
||||
|
||||
// positiveIntText 把一个「0 = 未声明」的整型渲染成可补的字符串:0 不算默认值
|
||||
// (见 groupFillSpecs 的说明),因此返回空串表示「env 层没有可补的值」。
|
||||
func positiveIntText(v int) string {
|
||||
if v <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
// parsePositiveInt 解析 env 层给出的整数;解析不出来(理论上不会发生:值来自
|
||||
// GroupFromEnv 的规范化十进制)时按未声明处理,绝不写进一个瞎猜的数。
|
||||
func parsePositiveInt(raw string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Fill 是一条补齐或跳过的记录:来源恒为 env 的那个键,落在哪个组上。
|
||||
|
||||
@@ -103,6 +103,14 @@ type Group struct {
|
||||
// last-resort fallback step then falls back to LLM_FALLBACK_MAX_TOKENS, and
|
||||
// beyond that the adapter default applies.
|
||||
MaxTokens int `json:"maxTokens,omitempty"`
|
||||
// ContextWindow 声明本组的模型上下文窗口(token)。它是**声明式**的窗口来源
|
||||
// (design/04 附录 A.3 第 1 条):本仓没有任何 provider 侧窗口发现代码,而
|
||||
// 上下文溢出压缩预算要按窗口推导(config.DeriveContextBudget),因此窗口必须
|
||||
// 由人来声明。0(默认,键被省略)= 未声明,推导回落到
|
||||
// config.DefaultModelContextWindow 并记一条 WARN;负数在校验阶段报错。
|
||||
//
|
||||
// 它是 `.env` 侧 LLM_CONTEXT_WINDOW 的对偶(per-group 播种,见 materialize.go)。
|
||||
ContextWindow int `json:"contextWindow,omitempty"`
|
||||
// PersonaModels binds an individual persona to its own model name within this
|
||||
// group (裁决 03.4.1: the "role" of per-role routing is the persona a turn
|
||||
// answers as, not the LLM message role). The carrier is deliberately
|
||||
@@ -150,6 +158,9 @@ func (g Group) Validate() error {
|
||||
if g.MaxTokens < 0 {
|
||||
return fmt.Errorf("maxTokens must be >= 0, got %d", g.MaxTokens)
|
||||
}
|
||||
if g.ContextWindow < 0 {
|
||||
return fmt.Errorf("contextWindow must be >= 0 (0 = 未声明,按回落窗口推导), got %d", g.ContextWindow)
|
||||
}
|
||||
if err := validatePersonaMap("personaModels", g.PersonaModels, false); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -325,6 +336,7 @@ func GroupFromEnv(env config.LLMConfig) []Group {
|
||||
Model: env.AnthropicModel,
|
||||
Fallback: env.AnthropicFallback,
|
||||
Multimodal: env.Multimodal,
|
||||
ContextWindow: env.ContextWindow,
|
||||
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
|
||||
}}
|
||||
}
|
||||
@@ -336,6 +348,7 @@ func GroupFromEnv(env config.LLMConfig) []Group {
|
||||
Model: env.Model,
|
||||
Fallback: env.Fallback,
|
||||
Multimodal: env.Multimodal,
|
||||
ContextWindow: env.ContextWindow,
|
||||
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,26 @@ import (
|
||||
// 抽取器的常量。它们全是编译期常量或纯函数分支,使抽取路径里不出现魔法数字。
|
||||
const (
|
||||
// ExtractorVersion 是抽取器的版本号,写进每条 sidecar 的元数据。抽取逻辑
|
||||
// 一旦变化(清洗规则、摘要口径、预算)必须递增:版本不匹配时旧 sidecar
|
||||
// 一旦变化(清洗规则、摘要口径、截断口径)必须递增:版本不匹配时旧 sidecar
|
||||
// 被判为失效并重抽一次,从而不会让「升级前抽出来的正文」永远留在缓存里
|
||||
// (design/24 §4.3 要求 sidecar 记「抽取器版本」,这就是它的用处)。
|
||||
ExtractorVersion = "documents/v1"
|
||||
//
|
||||
// v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限
|
||||
// (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。
|
||||
ExtractorVersion = "documents/v2"
|
||||
|
||||
// MaxExtractChars 是**单份文档**注入上下文的字符预算(design/24 §3.4、
|
||||
// §4.3 的 64K 字符)。超出即按 rune 截断并置 Result.Truncated;正文绝不
|
||||
// 静默消失 —— 循环据 result 渲染 truncated 与续读游标。
|
||||
MaxExtractChars = 64 * 1024
|
||||
// MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。
|
||||
//
|
||||
// 它取代了旧的 MaxExtractChars(64K 字符):那个数曾经同时兼任「抽取预算」与
|
||||
// 「注入预算」,导致 sidecar 里存的就是被注入预算砍过的正文 —— 于是清单行给
|
||||
// 出的续读游标(= 已注入字符数)恒等于 sidecar 正文长度,模型照它去
|
||||
// `document-read` 必然读到空(切片 T1 §7.1 实测的断裂)。
|
||||
//
|
||||
// 现在两件事分开:sidecar 存**抽取得到的全文**,只受这个安全上限约束(防止
|
||||
// 一份畸形或超大文件把内存/磁盘拖垮);每回合注入多少由**装配期**的注入预算
|
||||
// (loop.Options.DocumentMaxInjectChars)决定。命中本上限时 Truncated=true,
|
||||
// 如实记进 sidecar 元数据 —— 「正文到此为止」绝不等于「原文件到此为止」。
|
||||
MaxSidecarBytes = 8 << 20
|
||||
|
||||
// csvSummaryMaxColumns 限制摘要里列出的列名个数:超宽表只列前若干列,
|
||||
// 免得摘要本身挤占注入预算。
|
||||
@@ -72,12 +83,14 @@ var (
|
||||
// Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否
|
||||
// 截断、种类与抽取器版本,sidecar 元数据与注入渲染都只读它。
|
||||
type Result struct {
|
||||
// Text 是要注入模型可见上下文的文本(CSV/TSV 的摘要行 + 正文,已按 rune
|
||||
// 截断到预算内)。
|
||||
// Text 是要落进 sidecar(并在装配期按注入预算取用)的文本(CSV/TSV 的摘要行
|
||||
// + 正文,已按安全上限 MaxSidecarBytes 截断)。
|
||||
Text string
|
||||
// Chars 是 Text 的字符数(rune 数)。
|
||||
Chars int
|
||||
// Truncated 报告正文是否因预算被截断(design/24 §4.3:绝不静默截断)。
|
||||
// Truncated 报告正文是否撞上了**安全上限**(design/24 §4.3:绝不静默截断)。
|
||||
// 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进
|
||||
// history.DocumentRef(切片 T1 §7.2)。
|
||||
Truncated bool
|
||||
// Kind 是抽取出内容的种类(text / csv / tsv)。
|
||||
Kind string
|
||||
@@ -115,8 +128,8 @@ func Extract(name, mime string, data []byte) (Result, error) {
|
||||
text = summary + "\n" + body
|
||||
}
|
||||
res := Result{Kind: kind, ExtractorVersion: ExtractorVersion}
|
||||
if utf8.RuneCountInString(text) > MaxExtractChars {
|
||||
text = prefixRunes(text, MaxExtractChars)
|
||||
if len(text) > MaxSidecarBytes {
|
||||
text = prefixBytes(text, MaxSidecarBytes)
|
||||
res.Truncated = true
|
||||
}
|
||||
res.Text = text
|
||||
@@ -264,17 +277,19 @@ func splitColumns(line, delim string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// prefixRunes 返回 s 的前 n 个 rune:rune 安全,绝不把多字节字符切成两半。
|
||||
func prefixRunes(s string, n int) string {
|
||||
if n <= 0 {
|
||||
// prefixBytes 返回 s 的前若干个**完整 rune**,使其字节数不超过 max:
|
||||
// 安全上限按字节设(MaxSidecarBytes),因此截断必须落在 rune 边界上,
|
||||
// 绝不把多字节字符切成两半(否则 sidecar 正文会是非法 UTF-8)。
|
||||
func prefixBytes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return s[:i]
|
||||
}
|
||||
count++
|
||||
}
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
@@ -88,11 +88,15 @@ func TestExtract_TSVPrependsSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_TruncatesHonestly 钉住「绝不静默截断」(design/24 §4.3):超预算时
|
||||
// 截到预算、置 Truncated,且截断是 rune 安全的(多字节字符不被切成两半)。
|
||||
func TestExtract_TruncatesHonestly(t *testing.T) {
|
||||
// TestExtract_TruncatesAtSafetyCap 钉住「绝不静默截断」(design/24 §4.3):
|
||||
// 撞上**安全上限**(MaxSidecarBytes,8 MiB 文本)时截到上限并置 Truncated,且截断
|
||||
// 是 rune 安全的(多字节字符不被切成两半)。
|
||||
//
|
||||
// 注意它钉的不再是「注入预算」:注入预算由装配期按上下文预算决定(切片 T1 §7.2),
|
||||
// 抽取器只保证 sidecar 里存得下全文、且撞上上限时如实记账。
|
||||
func TestExtract_TruncatesAtSafetyCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := strings.Repeat("你好", MaxExtractChars)
|
||||
body := strings.Repeat("你好", MaxSidecarBytes/len("你好")+16)
|
||||
res, err := Extract(txtName, "text/plain", []byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
@@ -100,15 +104,37 @@ func TestExtract_TruncatesHonestly(t *testing.T) {
|
||||
if !res.Truncated {
|
||||
t.Fatal("Truncated = false, want true")
|
||||
}
|
||||
if res.Chars != MaxExtractChars {
|
||||
t.Fatalf("Chars = %d, want %d", res.Chars, MaxExtractChars)
|
||||
if len(res.Text) > MaxSidecarBytes {
|
||||
t.Fatalf("截断后正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes)
|
||||
}
|
||||
if want := strings.Repeat("你好", MaxExtractChars/2); res.Text != want {
|
||||
t.Fatalf("Text 不是预算内的 rune 前缀(len=%d)", len(res.Text))
|
||||
if res.Chars != utf8.RuneCountInString(res.Text) {
|
||||
t.Fatalf("Chars = %d, want 正文 rune 数 %d", res.Chars, utf8.RuneCountInString(res.Text))
|
||||
}
|
||||
if !utf8.ValidString(res.Text) {
|
||||
t.Fatal("截断后的正文不是合法 UTF-8")
|
||||
}
|
||||
// 上限内的正文逐字无损:截断只砍尾巴,不改写已有内容。
|
||||
if !strings.HasPrefix(body, res.Text) {
|
||||
t.Fatal("截断后的正文不是原文的前缀")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_KeepsFullTextBelowSafetyCap 钉住切片 T1 §7.2 的核心:抽取期**不再**
|
||||
// 按注入预算截断,因此一份远超旧 64K 字符、但仍在安全上限内的文档必须整份存下 ——
|
||||
// 否则 document-read 永远读不到注入预算之外的正文(S4 实测的断裂)。
|
||||
func TestExtract_KeepsFullTextBelowSafetyCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := strings.Repeat("宽", 200*1024) // 20 万字符:远超旧的 64K 注入预算
|
||||
res, err := Extract(txtName, "text/plain", []byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if res.Truncated {
|
||||
t.Fatal("Truncated = true, want false(安全上限内的正文不该被截断)")
|
||||
}
|
||||
if res.Chars != 200*1024 || res.Text != body {
|
||||
t.Fatalf("Chars = %d(want %d),正文是否逐字无损 = %v", res.Chars, 200*1024, res.Text == body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract_SanitizesInvalidUTF8 钉住「非法 UTF-8 要处理,不可 panic」:
|
||||
|
||||
@@ -46,10 +46,14 @@ const (
|
||||
sidecarDirMode = 0o700
|
||||
sidecarFileMode = 0o600
|
||||
|
||||
// maxSidecarBytes 是读 sidecar 时的硬上限(正文预算 64K 字符,UTF-8 最多
|
||||
// 4 字节/字符,再留元数据余量)。超过即判为损坏并重抽,绝不把一整份畸形
|
||||
// maxSidecarBytes 是读 sidecar 时的硬上限:正文安全上限(MaxSidecarBytes)
|
||||
// 再加元数据行与 JSON 编码的余量。超过即判为损坏并重抽,绝不把一整份畸形
|
||||
// 文件读进内存。
|
||||
maxSidecarBytes = MaxExtractChars*4 + 1<<10
|
||||
maxSidecarBytes = MaxSidecarBytes + sidecarReadAllowanceBytes
|
||||
|
||||
// sidecarReadAllowanceBytes 是给首行元数据(含 unreadable 的原因短句)与
|
||||
// JSON 编码留的余量。
|
||||
sidecarReadAllowanceBytes = 64 * 1024
|
||||
|
||||
// sidecarTempPattern 是原子写入用的临时文件前缀。
|
||||
sidecarTempPattern = ".sidecar-*"
|
||||
@@ -88,11 +92,12 @@ type cachedExtract struct {
|
||||
Reason string
|
||||
// Kind 是抽取所得内容的种类(text / csv / tsv)。
|
||||
Kind string
|
||||
// Chars 是注入正文的字符数。
|
||||
// Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。
|
||||
Chars int
|
||||
// Truncated 报告正文是否被预算截断。
|
||||
// Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」
|
||||
// 不等于「原文件到此为止」)。
|
||||
Truncated bool
|
||||
// Text 是 Status == extracted 时的注入正文。
|
||||
// Text 是 Status == extracted 时的全文正文。
|
||||
Text string
|
||||
}
|
||||
|
||||
@@ -393,6 +398,11 @@ func (r *Resolver) extract(part model.Content) cachedExtract {
|
||||
// documentRefFrom 把 sidecar 记录折进文档引用:身份(存储名、MIME、字节数)
|
||||
// 来自部件本身,status/chars/truncated 来自抽取结论。空状态归为 listed
|
||||
// (「清单已给出、正文未注入」是事实,而不是「已抽取」)。
|
||||
//
|
||||
// 注意这里给出的 chars/truncated 是**抽取侧**的事实(sidecar 正文的字符数、
|
||||
// 是否撞上安全上限)。装配期(loop.resolveDocuments)会按本回合的注入预算把
|
||||
// 它们改写成「实际注入了多少字符 / 注入是否被截断」—— 清单行的续读游标因此
|
||||
// 真的是「继续读的起点」(切片 T1 §7.2)。
|
||||
func documentRefFrom(rec cachedExtract, ref history.DocumentRef) history.DocumentRef {
|
||||
ref.Status = rec.Status
|
||||
ref.Chars = rec.Chars
|
||||
|
||||
@@ -33,7 +33,9 @@ type Sidecar struct {
|
||||
Kind string
|
||||
// Chars 是抽取期写盘时的正文字符数(rune)。
|
||||
Chars int
|
||||
// Truncated 报告抽取期的正文是否已按预算截断(design/24 §3.4 的 64K 字符)。
|
||||
// Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限
|
||||
// (documents.MaxSidecarBytes,8 MiB 文本)—— 它与「本回合注入被截断」是两件事:
|
||||
// 后者由装配期按注入预算判定并写进 history.DocumentRef。
|
||||
Truncated bool
|
||||
// Text 是正文本身:sidecar 第一行元数据**之后**的部分(含 CSV/TSV 的摘要行)。
|
||||
Text string
|
||||
|
||||
@@ -95,27 +95,37 @@ func TestResolver_ExtractsOnceAndThenReadsSidecar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_RecordsTruncationInSidecar 钉住截断统计随 sidecar 往返:第二次
|
||||
// 读 sidecar(原文件已删)仍然带着 truncated 与截断后的字符数。
|
||||
func TestResolver_RecordsTruncationInSidecar(t *testing.T) {
|
||||
// TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar 钉住 sidecar 的
|
||||
// 存全文语义(切片 T1 §7.2 第 1 条)与安全上限的如实记账:
|
||||
//
|
||||
// - 正文远超旧的 64K 注入预算、但未撞上安全上限 ⇒ 整份存下,truncated=false,
|
||||
// 因此 document-read 按清单游标续读能真的读到预算之外的正文;
|
||||
// - 撞上安全上限(MaxSidecarBytes)⇒ truncated=true 且字符数随 sidecar 往返。
|
||||
func TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
name := layout.writeUpload(t, txtName, []byte(strings.Repeat("宽", MaxExtractChars+10)))
|
||||
name := layout.writeUpload(t, txtName, []byte(strings.Repeat("宽", 100*1024)))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, _ := resolveOne(t, r, name)
|
||||
if !ref.Truncated || ref.Chars != MaxExtractChars {
|
||||
t.Fatalf("首次解析 = %+v, want truncated + Chars=%d", ref, MaxExtractChars)
|
||||
ref, body := resolveOne(t, r, name)
|
||||
if ref.Truncated || ref.Chars != 100*1024 {
|
||||
t.Fatalf("首次解析 = %+v, want 全文(%d 字符、未截断)", ref, 100*1024)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
||||
t.Fatalf("删除原文件: %v", err)
|
||||
}
|
||||
ref2, body2 := resolveOne(t, r, name)
|
||||
if !ref2.Truncated || ref2.Chars != MaxExtractChars {
|
||||
t.Fatalf("sidecar 命中 = %+v, want 保留 truncated + Chars", ref2)
|
||||
if ref2.Truncated || ref2.Chars != 100*1024 || body2 != body {
|
||||
t.Fatalf("sidecar 命中 = %+v, want 从 sidecar 读回同一份全文", ref2)
|
||||
}
|
||||
if len([]rune(body2)) != MaxExtractChars {
|
||||
t.Fatalf("sidecar 正文 rune 数 = %d, want %d", len([]rune(body2)), MaxExtractChars)
|
||||
|
||||
capped := layout.writeUpload(t, "0123456789abcdef0123456789abcdee.txt", []byte(strings.Repeat("宽", MaxSidecarBytes)))
|
||||
ref3, body3 := resolveOne(t, r, capped)
|
||||
if !ref3.Truncated {
|
||||
t.Fatalf("撞上安全上限的文档 = %+v, want truncated=true", ref3)
|
||||
}
|
||||
if ref3.Chars != len([]rune(body3)) || len(body3) > MaxSidecarBytes {
|
||||
t.Fatalf("上限命中:Chars=%d / 正文字节=%d,want 一致且不超过 %d", ref3.Chars, len(body3), MaxSidecarBytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,8 +209,14 @@ func documentResolution(part model.Content, refs map[string]history.DocumentRef,
|
||||
if ref.Status == history.DocumentStatusUnreadable && body == "" {
|
||||
body = documentUnreadableBody
|
||||
}
|
||||
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 一律按
|
||||
// listed 处理:状态不明时最诚实的说法是「只给了清单」。
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:真并发、不阻塞
|
||||
|
||||
@@ -97,10 +97,29 @@ type anthropicBlock struct {
|
||||
}
|
||||
|
||||
// anthropicUsage 上报 token 消耗。Anthropic 上报的是 input_tokens
|
||||
// 与 output_tokens,而不是单一总量。
|
||||
// 与 output_tokens,而不是单一总量;提示缓存另有两个字段
|
||||
// (cache_creation_input_tokens = 本次写入缓存的输入 token,
|
||||
// cache_read_input_tokens = 本次从缓存读到的输入 token)。它们全都容忍缺失:
|
||||
// 未启用缓存的端点只会给前两个。
|
||||
type anthropicUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
}
|
||||
|
||||
// applyAnthropicUsage 把一个 usage 对象折进 Response(只填用量字段,
|
||||
// input_tokens 与 output_tokens 的既有口径不变)。缓存两档的对应关系:
|
||||
// 命中 = cache_read_input_tokens;未命中 = input_tokens + cache_creation_input_tokens
|
||||
// —— Anthropic 的 input_tokens **不含**缓存读到的部分,若只取它,命中率会被算成 0。
|
||||
func applyAnthropicUsage(out *Response, u *anthropicUsage) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
out.PromptTokens = u.InputTokens
|
||||
out.CompletionTokens = u.OutputTokens
|
||||
out.CacheHitTokens = u.CacheReadInputTokens
|
||||
out.CacheMissTokens = u.InputTokens + u.CacheCreationInputTokens
|
||||
}
|
||||
|
||||
// AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。
|
||||
@@ -206,7 +225,7 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
a.record(req, start, out.TotalTokens)
|
||||
a.record(req, start, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -263,7 +282,7 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv
|
||||
return Response{}, err
|
||||
}
|
||||
out.Model = model
|
||||
a.record(req, start, out.TotalTokens)
|
||||
a.record(req, start, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -381,14 +400,17 @@ func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error {
|
||||
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
|
||||
func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int) {
|
||||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||||
func (a *AnthropicChatAdapter) record(req Request, start time.Time, resp Response) {
|
||||
logUsage(a.log, a.name, req, resp, time.Since(start))
|
||||
if a.recorder == nil {
|
||||
return
|
||||
}
|
||||
a.recorder(CallRecord{
|
||||
Model: req.Model,
|
||||
Purpose: req.Purpose,
|
||||
Tokens: tokens,
|
||||
Tokens: resp.TotalTokens,
|
||||
TookMs: time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
@@ -413,10 +435,9 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
|
||||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}}
|
||||
out.ToolCalls = toolCalls
|
||||
out.StopReason = wire.StopReason
|
||||
if u := wire.Usage; u != nil {
|
||||
out.PromptTokens = u.InputTokens
|
||||
out.CompletionTokens = u.OutputTokens
|
||||
out.TotalTokens = u.InputTokens + u.OutputTokens
|
||||
applyAnthropicUsage(&out, wire.Usage)
|
||||
if wire.Usage != nil {
|
||||
out.TotalTokens = wire.Usage.InputTokens + wire.Usage.OutputTokens
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -114,9 +114,10 @@ func applyAnthropicEvent(
|
||||
case "message_start":
|
||||
if evt.Message != nil {
|
||||
agg.Model = evt.Message.Model
|
||||
if evt.Message.Usage != nil {
|
||||
agg.PromptTokens = evt.Message.Usage.InputTokens
|
||||
}
|
||||
// 输入侧用量(含缓存读/写两档)在 message_start 就齐了,
|
||||
// 因此在这里一次折进去 —— 它与 message_delta 的输出侧用量
|
||||
// 合起来构成这次调用的完整成本面。
|
||||
applyAnthropicUsage(agg, evt.Message.Usage)
|
||||
}
|
||||
case "content_block_start":
|
||||
if evt.Index == nil || evt.Content == nil {
|
||||
|
||||
@@ -267,6 +267,33 @@ type Response struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
// CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数(design/04
|
||||
// 附录 A.1:提供商按缓存命中/未命中两档计价,命中价约为未命中的 1/50)。
|
||||
// 两种线格式都解析进这里:
|
||||
//
|
||||
// - OpenAI 兼容:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||
// (DeepSeek),缺失时用 prompt_tokens_details.cached_tokens 作命中数;
|
||||
// - Anthropic:usage.cache_read_input_tokens 作命中数,
|
||||
// input_tokens + cache_creation_input_tokens 作未命中数。
|
||||
//
|
||||
// 提供商未上报(缺字段)时为零 —— 缺字段是**容错**路径,绝不 panic、绝不
|
||||
// 影响正文解析。
|
||||
CacheHitTokens int
|
||||
CacheMissTokens int
|
||||
// ReasoningTokens 是完成侧里属于**思维链**的 token 数(OpenAI 兼容的
|
||||
// usage.completion_tokens_details.reasoning_tokens)。Anthropic 不上报它
|
||||
// (思考 token 已计入 output_tokens),因此那里恒为 0。
|
||||
ReasoningTokens int
|
||||
}
|
||||
|
||||
// CacheHitRate 返回输入侧的缓存命中率(hit / (hit+miss))。提供商未上报任何
|
||||
// 输入侧明细、或两档都是 0 时返回 0 —— 不编造一个 100% 或 0% 之外的假值。
|
||||
func (r Response) CacheHitRate() float64 {
|
||||
total := r.CacheHitTokens + r.CacheMissTokens
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.CacheHitTokens) / float64(total)
|
||||
}
|
||||
|
||||
// CallRecord 描述一次成功完成的 LLM 调用,供 M9
|
||||
|
||||
@@ -143,7 +143,7 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options)
|
||||
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
|
||||
}
|
||||
out := responseFromWire(&wire)
|
||||
a.record(req, start, out.TotalTokens)
|
||||
a.record(req, start, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent
|
||||
return Response{}, err
|
||||
}
|
||||
out.Model = model
|
||||
a.record(req, start, out.TotalTokens)
|
||||
a.record(req, start, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -230,14 +230,17 @@ func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
|
||||
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
|
||||
func (a *OpenAIChatAdapter) record(req Request, start time.Time, tokens int) {
|
||||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||||
func (a *OpenAIChatAdapter) record(req Request, start time.Time, resp Response) {
|
||||
logUsage(a.log, a.name, req, resp, time.Since(start))
|
||||
if a.recorder == nil {
|
||||
return
|
||||
}
|
||||
a.recorder(CallRecord{
|
||||
Model: req.Model,
|
||||
Purpose: req.Purpose,
|
||||
Tokens: tokens,
|
||||
Tokens: resp.TotalTokens,
|
||||
TookMs: time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
@@ -48,11 +48,53 @@ type openAIToolCallDelta struct {
|
||||
Function openAIToolCallFn `json:"function"`
|
||||
}
|
||||
|
||||
// openAIUsage 上报 token 消耗。
|
||||
// openAIUsage 上报 token 消耗。缓存两档(prompt_cache_hit_tokens /
|
||||
// prompt_cache_miss_tokens)由 DeepSeek 上报;OpenAI 兼容家族里另有
|
||||
// prompt_tokens_details.cached_tokens 表达同一件事,两者都解析、缺一个就用
|
||||
// 另一个(见 applyOpenAIUsage)。
|
||||
type openAIUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
|
||||
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
|
||||
PromptTokensDetails openAIPromptTokenDetails `json:"prompt_tokens_details"`
|
||||
CompletionTokenDetails openAICompletionTokenDetails `json:"completion_tokens_details"`
|
||||
}
|
||||
|
||||
// openAIPromptTokenDetails 是输入侧明细(OpenAI / DashScope 形状)。
|
||||
type openAIPromptTokenDetails struct {
|
||||
// CachedTokens 是命中上下文缓存的 token 数,与 prompt_cache_hit_tokens 同义。
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
// openAICompletionTokenDetails 是完成侧明细:推理模型把思维链 token 报在这里。
|
||||
type openAICompletionTokenDetails struct {
|
||||
// ReasoningTokens 是思维链 token 数(design/04 附录 A.3 第 4 条要观测它)。
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
}
|
||||
|
||||
// applyOpenAIUsage 把一个 usage 对象折进 Response。它容忍任何字段缺失:
|
||||
// 缺失即零值,绝不 panic,也绝不因为用量字段的形态问题影响正文解析。
|
||||
//
|
||||
// 未命中的补齐用的是提供商自己的不变式「prompt_tokens = hit + miss」(见
|
||||
// DeepSeek 文档)—— 只在提供商没给 miss 时才补,免得把命中率算成 100%。
|
||||
func applyOpenAIUsage(out *Response, u *openAIUsage) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
out.PromptTokens = u.PromptTokens
|
||||
out.CompletionTokens = u.CompletionTokens
|
||||
out.TotalTokens = u.TotalTokens
|
||||
out.CacheHitTokens = u.PromptCacheHitTokens
|
||||
if out.CacheHitTokens == 0 {
|
||||
out.CacheHitTokens = u.PromptTokensDetails.CachedTokens
|
||||
}
|
||||
out.CacheMissTokens = u.PromptCacheMissTokens
|
||||
if out.CacheMissTokens == 0 && out.PromptTokens > out.CacheHitTokens {
|
||||
out.CacheMissTokens = out.PromptTokens - out.CacheHitTokens
|
||||
}
|
||||
out.ReasoningTokens = u.CompletionTokenDetails.ReasoningTokens
|
||||
}
|
||||
|
||||
func responseFromWire(wire *openAIChatResponse) Response {
|
||||
@@ -70,10 +112,6 @@ func responseFromWire(wire *openAIChatResponse) Response {
|
||||
out.ToolCalls = append(out.ToolCalls, ToolCall{Name: tc.Function.Name, ID: tc.ID, Args: args})
|
||||
}
|
||||
}
|
||||
if u := wire.Usage; u != nil {
|
||||
out.PromptTokens = u.PromptTokens
|
||||
out.CompletionTokens = u.CompletionTokens
|
||||
out.TotalTokens = u.TotalTokens
|
||||
}
|
||||
applyOpenAIUsage(&out, wire.Usage)
|
||||
return out
|
||||
}
|
||||
@@ -88,9 +88,9 @@ func (st *openAIStreamState) emit(ev StreamEvent) {
|
||||
// 片段增量(若有)则被路由到其处理器。
|
||||
func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
|
||||
if u := chunk.Usage; u != nil {
|
||||
st.agg.PromptTokens = u.PromptTokens
|
||||
st.agg.CompletionTokens = u.CompletionTokens
|
||||
st.agg.TotalTokens = u.TotalTokens
|
||||
// 用量块(含缓存的 hit/miss 与思维链 token)只在启用 include_usage 时
|
||||
// 随末块到达,且必须原样折进聚合结果 —— 这正是「成本可观测」的落点。
|
||||
applyOpenAIUsage(&st.agg, u)
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
return
|
||||
|
||||
@@ -170,8 +170,10 @@ func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
|
||||
if got := (<-ch).body["reasoning_effort"]; got != "high" {
|
||||
t.Fatalf("body.reasoning_effort = %v, want high", got)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("unexpected log for a supported value: %q", buf.String())
|
||||
// 用量日志(每次调用一条 Info)不算「意外日志」:这里断言的是**没有 WARN**
|
||||
// —— 受支持的档位不该被上报成问题。
|
||||
if strings.Contains(buf.String(), "level=WARN") {
|
||||
t.Errorf("unexpected WARN for a supported value: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是**用量与缓存的可观测出口**(design/04 附录 A.3 第 4 条)。
|
||||
//
|
||||
// 背景(附录 A.2 第 3 条):在此之前输入侧用量从未被记录 —— 全仓只消费了
|
||||
// PromptTokens / TotalTokens,`prompt_cache_hit_tokens` / `miss` 一律没解析、没落
|
||||
// 日志,于是「大上下文到底贵不贵」根本无法实测。而提供商按命中/未命中两档计价,
|
||||
// 命中价约为未命中的 1/50:看不见命中率,就等于看不见成本。
|
||||
//
|
||||
// 记录点选在**适配器内部**(每个适配器的 record 都调它一次),而不是循环里:
|
||||
// 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到,
|
||||
// 不会有「换个调用入口就没了成本日志」的缺口。
|
||||
//
|
||||
// 红线:日志里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey / token)绝不
|
||||
// 出现在这里**,它连参数都进不来。
|
||||
|
||||
// usageLogEvent 是每条用量日志的事件名,便于按事件过滤与统计。
|
||||
const usageLogEvent = "model usage"
|
||||
|
||||
// logUsage 记一条结构化用量日志:prompt / completion / total / reasoning /
|
||||
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时。
|
||||
//
|
||||
// log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御:
|
||||
// 缺字段容错、绝不 panic。
|
||||
func logUsage(log *slog.Logger, adapter string, req Request, resp Response, took time.Duration) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
log.Info(usageLogEvent,
|
||||
"adapter", adapter,
|
||||
"model", resp.Model,
|
||||
"purpose", req.Purpose,
|
||||
"prompt", resp.PromptTokens,
|
||||
"completion", resp.CompletionTokens,
|
||||
"total", resp.TotalTokens,
|
||||
"reasoning", resp.ReasoningTokens,
|
||||
"cacheHit", resp.CacheHitTokens,
|
||||
"cacheMiss", resp.CacheMissTokens,
|
||||
"hitRate", resp.CacheHitRate(),
|
||||
"tookMs", took.Milliseconds(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件钉住 usage/缓存可观测(design/04 附录 A.3 第 4 条):
|
||||
// - 两种线格式的缓存字段都解析进 Response,**缺字段是容错路径**(不 panic、不影响正文);
|
||||
// - 每次模型调用后记一条结构化用量日志,字段齐全且**不含任何凭据**。
|
||||
|
||||
// TestOpenAIUsage_ParsesCacheAndReasoningTokens 钉住 OpenAI 兼容形状:DeepSeek 式的
|
||||
// prompt_cache_hit_tokens / prompt_cache_miss_tokens 与 completion_tokens_details
|
||||
// .reasoning_tokens 都进 Response。
|
||||
func TestOpenAIUsage_ParsesCacheAndReasoningTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := `{
|
||||
"id": "cmpl-1",
|
||||
"model": "deepseek-chat",
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"你好"},"finish_reason":"stop"}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200,
|
||||
"prompt_cache_hit_tokens": 900, "prompt_cache_miss_tokens": 100,
|
||||
"completion_tokens_details": {"reasoning_tokens": 150}
|
||||
}
|
||||
}`
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil)
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"})
|
||||
|
||||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.PromptTokens != 1000 || resp.CompletionTokens != 200 || resp.TotalTokens != 1200 {
|
||||
t.Fatalf("用量 = (%d,%d,%d), want (1000,200,1200)", resp.PromptTokens, resp.CompletionTokens, resp.TotalTokens)
|
||||
}
|
||||
if resp.CacheHitTokens != 900 || resp.CacheMissTokens != 100 {
|
||||
t.Fatalf("缓存两档 = (%d,%d), want (900,100)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||||
}
|
||||
if resp.ReasoningTokens != 150 {
|
||||
t.Fatalf("ReasoningTokens = %d, want 150", resp.ReasoningTokens)
|
||||
}
|
||||
if got := resp.CacheHitRate(); got != 0.9 {
|
||||
t.Fatalf("命中率 = %v, want 0.9", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIUsage_FallsBackToCachedTokensDetails 钉住另一条 OpenAI 兼容形态:
|
||||
// 只有 prompt_tokens_details.cached_tokens(OpenAI / DashScope)时也能得到命中数,
|
||||
// 且未命中数按提供商自己的不变式(prompt = hit + miss)补齐,而不是把命中率算成 100%。
|
||||
func TestOpenAIUsage_FallsBackToCachedTokensDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := `{
|
||||
"id": "cmpl-2", "model": "gpt-test",
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
|
||||
"usage": {"prompt_tokens": 400, "completion_tokens": 10, "total_tokens": 410,
|
||||
"prompt_tokens_details": {"cached_tokens": 300}}
|
||||
}`
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil)
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
|
||||
|
||||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.CacheHitTokens != 300 || resp.CacheMissTokens != 100 {
|
||||
t.Fatalf("缓存两档 = (%d,%d), want (300,100)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsage_MissingFieldsAreTolerated 钉住缺字段容错:用量块只有最基本的三个数时
|
||||
// 不 panic、不报错,缓存命中数为零,未命中数按提供商自己的不变式(prompt = hit +
|
||||
// miss)补齐 —— 也就是「这次输入全都没命中缓存」,命中率因此是 0 而不是编造值。
|
||||
func TestUsage_MissingFieldsAreTolerated(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
|
||||
|
||||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.CacheHitTokens != 0 || resp.CacheMissTokens != 5 || resp.ReasoningTokens != 0 {
|
||||
t.Fatalf("缺失字段 = (%d,%d,%d), want (0,5,0)", resp.CacheHitTokens, resp.CacheMissTokens, resp.ReasoningTokens)
|
||||
}
|
||||
if resp.CacheHitRate() != 0 {
|
||||
t.Fatalf("无缓存命中时的命中率 = %v, want 0", resp.CacheHitRate())
|
||||
}
|
||||
if resp.Content != "Hello!" {
|
||||
t.Fatalf("正文受用量字段影响: %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIStreamUsage_ParsesCacheFields 钉住流式末块的用量块:缓存的 hit/miss 与
|
||||
// 思维链 token 一样原样折进聚合结果(流式是最常用的路径,漏了它等于没做)。
|
||||
func TestOpenAIStreamUsage_ParsesCacheFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
lines := []string{
|
||||
`data: {"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"你好"}}]}`,
|
||||
`data: {"model":"deepseek-chat","choices":[],"usage":{"prompt_tokens":500,"completion_tokens":50,"total_tokens":550,"prompt_cache_hit_tokens":480,"prompt_cache_miss_tokens":20,"completion_tokens_details":{"reasoning_tokens":30}}}`,
|
||||
`data: [DONE]`,
|
||||
}
|
||||
srv := newCompletionServer(t, http.StatusOK, "text/event-stream", strings.Join(lines, "\n")+"\n", nil)
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"})
|
||||
|
||||
resp, err := ad.ChatStream(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream: %v", err)
|
||||
}
|
||||
if resp.PromptTokens != 500 || resp.CacheHitTokens != 480 || resp.CacheMissTokens != 20 || resp.ReasoningTokens != 30 {
|
||||
t.Fatalf("流式用量 = %+v, want prompt 500 / hit 480 / miss 20 / reasoning 30", resp)
|
||||
}
|
||||
if resp.Content != "你好" {
|
||||
t.Fatalf("流式正文 = %q, want 你好", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicUsage_ParsesPromptCacheFields 钉住 Anthropic 形状:命中 =
|
||||
// cache_read_input_tokens,未命中 = input_tokens + cache_creation_input_tokens
|
||||
// (Anthropic 的 input_tokens 不含缓存读到的部分,只取它会把命中率算成 0)。
|
||||
func TestAnthropicUsage_ParsesPromptCacheFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := `{
|
||||
"id": "msg_1", "model": "claude-test", "stop_reason": "end_turn",
|
||||
"content": [{"type":"text","text":"你好"}],
|
||||
"usage": {"input_tokens": 100, "output_tokens": 20,
|
||||
"cache_creation_input_tokens": 300, "cache_read_input_tokens": 700}
|
||||
}`
|
||||
srv := newAnthropicServer(t, http.StatusOK, "application/json", body, nil)
|
||||
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
|
||||
|
||||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.CacheHitTokens != 700 || resp.CacheMissTokens != 400 {
|
||||
t.Fatalf("缓存两档 = (%d,%d), want (700,400)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||||
}
|
||||
// 既有口径不变:input_tokens 仍然只算 PromptTokens。
|
||||
if resp.PromptTokens != 100 || resp.TotalTokens != 120 {
|
||||
t.Fatalf("用量 = (%d,...,%d), want prompt 100 / total 120", resp.PromptTokens, resp.TotalTokens)
|
||||
}
|
||||
if resp.ReasoningTokens != 0 {
|
||||
t.Fatalf("Anthropic 不单独上报思维链 token,ReasoningTokens = %d, want 0", resp.ReasoningTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicUsage_MissingCacheFieldsAreTolerated 钉住 Anthropic 的缺字段容错:
|
||||
// 未启用提示缓存的端点只给 input/output 两个数,解析照常、缓存为零。
|
||||
func TestAnthropicUsage_MissingCacheFieldsAreTolerated(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, nil)
|
||||
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
|
||||
|
||||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.CacheHitTokens != 0 || resp.CacheMissTokens != 5 {
|
||||
// anthropicTextResponse 的 usage 是 input_tokens=5 / output_tokens=2:
|
||||
// 未命中数 = input + cache_creation = 5(缓存字段缺失时即为输入本身)。
|
||||
t.Fatalf("缓存两档 = (%d,%d), want (0,5)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageLog_HasAllFieldsAndNoCredentials 钉住每次模型调用后那条结构化日志:
|
||||
// prompt / completion / total / reasoning / cacheHit / cacheMiss / hitRate 一个不少,
|
||||
// 且**绝不出现凭据**(apiKey / token)。
|
||||
func TestUsageLog_HasAllFieldsAndNoCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-super-secret-credential"
|
||||
body := `{
|
||||
"id": "cmpl-1", "model": "deepseek-chat",
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"你好"},"finish_reason":"stop"}],
|
||||
"usage": {"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200,
|
||||
"prompt_cache_hit_tokens": 900, "prompt_cache_miss_tokens": 100,
|
||||
"completion_tokens_details": {"reasoning_tokens": 150}}
|
||||
}`
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil)
|
||||
log, buf := captureJSONLog()
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, APIKey: secret, Model: "deepseek-chat"},
|
||||
WithOpenAILogger(log))
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Purpose: "chat",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
|
||||
rec := usageLogRecord(t, buf.String())
|
||||
if rec["msg"] != usageLogEvent {
|
||||
t.Fatalf("事件名 = %v, want %q", rec["msg"], usageLogEvent)
|
||||
}
|
||||
for field, want := range map[string]float64{
|
||||
"prompt": 1000, "completion": 200, "total": 1200, "reasoning": 150,
|
||||
"cacheHit": 900, "cacheMiss": 100, "hitRate": 0.9,
|
||||
} {
|
||||
if got, ok := rec[field].(float64); !ok || got != want {
|
||||
t.Fatalf("日志字段 %s = %v, want %v(完整记录:%v)", field, rec[field], want, rec)
|
||||
}
|
||||
}
|
||||
if rec["model"] != "deepseek-chat" || rec["purpose"] != "chat" {
|
||||
t.Fatalf("日志缺少模型/用途:%v", rec)
|
||||
}
|
||||
raw := buf.String()
|
||||
for _, credential := range []string{secret, "Authorization", "apiKey", "Bearer"} {
|
||||
if strings.Contains(raw, credential) {
|
||||
t.Fatalf("用量日志里出现了凭据相关内容 %q:%s", credential, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureJSONLog 返回输出 JSON 记录的 logger 与缓冲:用量日志要按**字段**断言,
|
||||
// 因此不能像 reasoning 用例那样只看文本子串。
|
||||
func captureJSONLog() (*slog.Logger, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
|
||||
// usageLogRecord 从捕获的日志文本里取出唯一一条用量记录(找不到或不止一条就报错)。
|
||||
func usageLogRecord(t *testing.T, raw string) map[string]any {
|
||||
t.Helper()
|
||||
var found []map[string]any
|
||||
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.Contains(line, `"msg":"`+usageLogEvent+`"`) {
|
||||
continue
|
||||
}
|
||||
rec := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("用量日志不是合法 JSON 行: %v(%q)", err, line)
|
||||
}
|
||||
found = append(found, rec)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("用量日志条数 = %d, want 1(每次调用恰好一条):%s", len(found), raw)
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
@@ -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. " +
|
||||
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; 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."
|
||||
"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) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// 本文件钉住 document-read 与**装配期注入预算**之间的成对契约(切片 T1 §7.2):
|
||||
// sidecar 存抽取全文,注入只在装配期按预算截断,因此清单行给出的续读游标真的是
|
||||
// 「继续读的起点」。工具自身的分页/边界/诚实失败用例在 document_read_test.go。
|
||||
|
||||
// TestDocumentReadTool_CursorContinuesIntoTextBeyondTheInjectionBudget 钉住切片 T1
|
||||
// §7.2 修好的断裂(S4 实测:清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必然读到空):
|
||||
//
|
||||
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
|
||||
// - sidecar 存的是**抽取全文**:正文远超旧的 64K 注入预算时,`Chars` 仍是全文长度、
|
||||
// `Truncated=false`,因此模型照清单给的游标(= 已注入字符数)续读,拿到的是
|
||||
// 注入预算**之外**的真实正文,而不是空。
|
||||
func TestDocumentReadTool_CursorContinuesIntoTextBeyondTheInjectionBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
tool := fx.tool(t)
|
||||
|
||||
csv := docName(".csv")
|
||||
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
|
||||
if !strings.HasPrefix(injectedCSV, "[CSV") {
|
||||
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
|
||||
}
|
||||
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
|
||||
if err != nil {
|
||||
t.Fatalf("读 CSV: %v", err)
|
||||
}
|
||||
if gotCSV.Text != injectedCSV {
|
||||
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
|
||||
}
|
||||
|
||||
// 正文 20 万字符:远超旧的 64K 注入预算,但远在 sidecar 安全上限内。
|
||||
body := strings.Repeat("正", 200*1024)
|
||||
big := docName(".txt")
|
||||
ref, injected := fx.extract(t, big, body)
|
||||
if ref.Truncated || ref.Chars != len([]rune(body)) {
|
||||
t.Fatalf("夹具前提:ref = %+v, want 全文 %d 字符、未撞安全上限", ref, len([]rune(body)))
|
||||
}
|
||||
if injected != body {
|
||||
t.Fatalf("注入正文长度 = %d, want 全文 %d", len([]rune(injected)), len([]rune(body)))
|
||||
}
|
||||
|
||||
// 装配期若按旧预算(64K 字符)注入,游标就是 64K;按它续读必须拿到预算之外的正文。
|
||||
const injectedByOldBudget = 64 * 1024
|
||||
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": injectedByOldBudget})
|
||||
if err != nil {
|
||||
t.Fatalf("按清单游标读取: %v", err)
|
||||
}
|
||||
if atCursor.TotalChars != len([]rune(body)) {
|
||||
t.Fatalf("totalChars = %d, want sidecar 全文 %d", atCursor.TotalChars, len([]rune(body)))
|
||||
}
|
||||
if atCursor.Text == "" {
|
||||
t.Fatal("按游标续读读到了空正文 —— 清单给的承诺是假的(S4 的断裂没有修好)")
|
||||
}
|
||||
if !atCursor.Truncated || atCursor.NextOffset != injectedByOldBudget+len([]rune(atCursor.Text)) {
|
||||
t.Fatalf("续读窗口 = %+v, want 被 limit 截断且带 nextOffset", atCursor)
|
||||
}
|
||||
if want := body[injectedByOldBudget*len("正"):][:len(atCursor.Text)]; atCursor.Text != want {
|
||||
t.Fatalf("续读正文不是注入预算之外的那一段:前 40 字符 %q", firstRunes(atCursor.Text, 40))
|
||||
}
|
||||
if atCursor.ExtractTruncated {
|
||||
t.Fatal("未撞安全上限的文档不该报 extractTruncated")
|
||||
}
|
||||
}
|
||||
|
||||
// firstRunes 返回 s 的前 n 个 rune(只用于失败信息,避免把整段正文打进日志)。
|
||||
func firstRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_SafetyCapTruncationIsHonest 钉住与抽取侧的另一半成对契约:
|
||||
// 正文撞上 sidecar 安全上限(documents.MaxSidecarBytes)时,sidecar 只有那段前缀,
|
||||
// 工具照实把「抽取期就到头了」告诉模型(extractTruncated=true),使「读到末尾」
|
||||
// 不会被误当成「文件只有这么长」。
|
||||
func TestDocumentReadTool_SafetyCapTruncationIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
name := docName(".txt")
|
||||
ref, injected := fx.extract(t, name, strings.Repeat("宽", documents.MaxSidecarBytes/len("宽")+16))
|
||||
if !ref.Truncated {
|
||||
t.Fatalf("夹具前提:ref = %+v, want 撞上安全上限并截断", ref)
|
||||
}
|
||||
if ref.Chars != len([]rune(injected)) || ref.Chars > documents.MaxSidecarBytes/len("宽") {
|
||||
t.Fatalf("撞上限后的字符数 = %d, want 不超过 %d 且与正文一致", ref.Chars, documents.MaxSidecarBytes/len("宽"))
|
||||
}
|
||||
atEnd, err := callDocRead(t, fx.tool(t), map[string]any{"name": name, "offset": ref.Chars})
|
||||
if err != nil {
|
||||
t.Fatalf("按末尾游标读取: %v", err)
|
||||
}
|
||||
if atEnd.TotalChars != ref.Chars || atEnd.Text != "" || atEnd.Truncated {
|
||||
t.Fatalf("末尾读取 = %+v, want 读到 sidecar 末尾(totalChars %d)", atEnd, ref.Chars)
|
||||
}
|
||||
if !atEnd.ExtractTruncated {
|
||||
t.Fatal("抽取期的安全上限截断没有如实回给模型(extractTruncated=false)")
|
||||
}
|
||||
}
|
||||
@@ -360,46 +360,6 @@ func TestDocumentReadTool_TextExcludesMetadataLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_ManifestCursorAndCSVSummary 钉住与上游的两处成对契约:
|
||||
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
|
||||
// - 清单行给的续读游标(= 已注入字符数)可以原样传进来:读到末尾不报错,并如实标注
|
||||
// 抽取期的截断(extractTruncated)—— 抽取期预算之外的内容**不在 sidecar 里**
|
||||
// (design/24 §9.6 第 3 条),这条事实必须让模型看得见。
|
||||
func TestDocumentReadTool_ManifestCursorAndCSVSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
fx := newDocReadFixture(t)
|
||||
tool := fx.tool(t)
|
||||
|
||||
csv := docName(".csv")
|
||||
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
|
||||
if !strings.HasPrefix(injectedCSV, "[CSV") {
|
||||
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
|
||||
}
|
||||
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
|
||||
if err != nil {
|
||||
t.Fatalf("读 CSV: %v", err)
|
||||
}
|
||||
if gotCSV.Text != injectedCSV {
|
||||
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
|
||||
}
|
||||
|
||||
big := docName(".txt")
|
||||
ref, _ := fx.extract(t, big, strings.Repeat("宽", documents.MaxExtractChars+10))
|
||||
if !ref.Truncated || ref.Chars != documents.MaxExtractChars {
|
||||
t.Fatalf("夹具前提:ref = %+v, want 抽取期已截断到 %d 字符", ref, documents.MaxExtractChars)
|
||||
}
|
||||
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": ref.Chars})
|
||||
if err != nil {
|
||||
t.Fatalf("按清单游标读取: %v", err)
|
||||
}
|
||||
if atCursor.TotalChars != ref.Chars || atCursor.Text != "" || atCursor.Truncated || atCursor.NextOffset != 0 {
|
||||
t.Fatalf("按清单游标读取 = %+v, want 读到抽取正文末尾(totalChars %d)", atCursor, ref.Chars)
|
||||
}
|
||||
if !atCursor.ExtractTruncated {
|
||||
t.Fatal("抽取期的截断事实没有如实回给模型(extractTruncated=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentReadTool_OwnerOnlyByDefault 钉住⑤:工具以**默认最严**的可达性标记注册
|
||||
// (tool_policies 无行 = 两者皆 false = owner-only),因此群聊/陌生人会话在默认策略下既
|
||||
// 看不到也调不到它(design/24 §5 红线)。注册走的是既有工具同一条路径
|
||||
|
||||
Reference in new issue
Block a user