- 窗口声明: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)。
338 lines
15 KiB
Go
338 lines
15 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件归属循环的**装配期文档解析**(design/24 §2.3-①、§4):把每一步模型
|
||
// 可见视图里的文档部件确定性替换为「清单行 + 正文」文本块。三条硬规则在这里
|
||
// 落地,缺一不可:
|
||
//
|
||
// 1. **绝不把文档部件交给编码器**:替换是无条件的(解析器缺席或失败也一样),
|
||
// 因此编码器那侧的 fail-loud 永远不该被触发 —— 一旦触发就是编程错误;
|
||
// 2. **清单恒在**:每份文档至少要留下「名、MIME、大小、结论」这一行,
|
||
// 绝不出现「传上去了但模型一无所知」的空上下文(§4.2-3);
|
||
// 3. **失败必须可见**:抽取失败解析为诚实的文本标记 + status=unreadable,
|
||
// 回合照常继续(§4.4);
|
||
// 4. **注入预算在这里生效**(切片 T1 §7.2):正文按本回合的注入预算
|
||
// (l.documentInjectChars)截取,ref.Chars = 实际注入的字符数、
|
||
// ref.Truncated = 注入是否被截断。sidecar 存的是抽取全文,因此清单行给出的
|
||
// 续读游标从此**真的是**「继续读的起点」,与 document-read 的 offset 对齐。
|
||
//
|
||
// 它作用于**装配后的模型可见视图**而不是消息窗口:窗口(checkpoint 快照、
|
||
// 记忆提取输入)里只留文档引用(§2.4),正文每次装配时按引用读 sidecar 得到。
|
||
const (
|
||
// defaultDocumentInjectChars 是单份文档注入上下文的**字符**上限的缺省值
|
||
// (64K 字符)。它的实际取值由组合根按推导出的上下文预算给出
|
||
// (Options.DocumentMaxInjectChars / DocumentInjectCharsFor):64K 是
|
||
// 「小窗口下不退化」的下限,不再兼任抽取期的截断预算 —— 抽取期只受
|
||
// documents.MaxSidecarBytes 这个安全上限约束(切片 T1 §7.2)。
|
||
//
|
||
// 它同时是压缩估算里文档部件代价的封顶依据(compactor.go 的
|
||
// documentPartTokens):注入多少,就按多少计入 token。
|
||
defaultDocumentInjectChars = 64 * 1024
|
||
|
||
// documentMinTokens 是清单行(名/MIME/大小/结论)的粗略 token 下限:无论
|
||
// 文档多小、甚至完全读不到,清单行都进了上下文,因此它不是免费的。
|
||
documentMinTokens = 32
|
||
|
||
// documentUnknownMIME 是清单行里 MIME 缺失时的展示值 —— 宁可写「未知类型」
|
||
// 也不留一个空洞让模型误读成「没有问题」。
|
||
documentUnknownMIME = "未知类型"
|
||
|
||
// 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 (
|
||
documentListPrefix = "[附件 "
|
||
documentListSeparator = " · "
|
||
documentListSuffix = "]"
|
||
|
||
documentNoteExtracted = "已注入正文 %d 字符"
|
||
documentNoteTruncated = "已注入正文 %d 字符(已截断,续读游标 offset=%d;可用 document-read 继续读取)"
|
||
documentNoteListed = "仅清单(本次未注入正文)"
|
||
documentNoteUnreadable = "不可读"
|
||
|
||
// documentUnreadableBody 是解析器没能给出原因时的诚实兜底文案:不静默,
|
||
// 也不编造一个具体原因。
|
||
documentUnreadableBody = "无法抽取正文:读取或解析失败"
|
||
)
|
||
|
||
// DocumentResolver 是循环通向文档摄取层的接缝(design/24 §2.3-①、§4)。
|
||
//
|
||
// 形状与 ImageResolver 同族:注入式依赖、装配期按消息调用一次、失败时回合
|
||
// 继续。差别只在返回值 —— 图像解析就地改写一条消息,而文档解析把「结论」与
|
||
// 「正文」分开交回:清单行、截断标记与「清单恒在」由循环统一渲染(解析器缺席
|
||
// 或失败时也必须成立),status/chars/truncated 则经 Result.Documents 回给
|
||
// 客户端(§4.4、§6.1)。
|
||
//
|
||
// 两份映射都以**服务端存储名**为键,值分别是该文档的结论(形状即
|
||
// history.DocumentRef,与落库/DTO 同一个结构体)与要注入的正文(读不到时是
|
||
// 诚实的原因文案)。缺少条目的文档由循环降级为「仅清单」。用这些中立类型,
|
||
// 摄取层不必依赖 loop 包,循环也不必依赖摄取层 —— 与 vision.MessageResolver
|
||
// 满足 ImageResolver 的方式相同。
|
||
type DocumentResolver interface {
|
||
// Resolve 解析 msg 里的文档部件。error 表示整条消息都解析不了(例如回合
|
||
// 已被取消);**单份文档**的失败不是 error,而是 unreadable 结论。
|
||
Resolve(ctx context.Context, msg model.Message) (map[string]history.DocumentRef, map[string]string, error)
|
||
}
|
||
|
||
// resolveDocuments 把每条消息里的文档部件确定性替换为「清单行 + 正文」文本块,
|
||
// 并汇总本次解析到的结论。没有文档部件时原样返回输入切片(与 resolveImages
|
||
// 的短路同一口径)。
|
||
func (l *Loop) resolveDocuments(ctx context.Context, messages []model.Message) ([]model.Message, []history.DocumentRef) {
|
||
if !anyDocumentPart(messages) {
|
||
return messages, nil
|
||
}
|
||
out := make([]model.Message, 0, len(messages))
|
||
refs := make([]history.DocumentRef, 0, len(messages))
|
||
for _, m := range messages {
|
||
if !messageHasDocument(m) {
|
||
out = append(out, m)
|
||
continue
|
||
}
|
||
resolved, docRefs := l.resolveDocumentMessage(ctx, m)
|
||
out = append(out, resolved)
|
||
refs = append(refs, docRefs...)
|
||
}
|
||
return out, refs
|
||
}
|
||
|
||
// resolveDocumentMessage 解析一条消息:文档部件就地换成文本块,其余部件
|
||
// (文本、图片、工具结果)逐字保留,相对顺序不变。
|
||
func (l *Loop) resolveDocumentMessage(ctx context.Context, m model.Message) (model.Message, []history.DocumentRef) {
|
||
count := countDocumentParts(m)
|
||
if m.Role != model.RoleUser {
|
||
// 红线(design/11 §10.2、design/24 §2.3-③):文档部件只允许挂在用户
|
||
// 回合上。这里不静默:留下带来源的日志,并把部件确定性中和掉 ——
|
||
// 中和而不是丢弃,清单因此仍然出现在上下文里。
|
||
l.log.Warn("document part on non-user message", "role", string(m.Role), "documents", count)
|
||
}
|
||
refs, bodies, err := l.documentResolutions(ctx, m)
|
||
failed := err != nil
|
||
if failed {
|
||
// 解析失败在这里留下可诊断信号:原消息保留、回合继续,但绝不静默 ——
|
||
// 下面的降级渲染给出诚实标记,清单照旧。日志只记数量,绝不记正文。
|
||
l.log.Warn("document resolve", "error", err, "documents", count)
|
||
refs, bodies = nil, nil
|
||
}
|
||
content := make([]model.Content, 0, len(m.Content))
|
||
out := make([]history.DocumentRef, 0, count)
|
||
for _, part := range m.Content {
|
||
if part.Type != model.ContentTypeDocument {
|
||
content = append(content, part)
|
||
continue
|
||
}
|
||
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)
|
||
}
|
||
m.Content = content
|
||
return m, out
|
||
}
|
||
|
||
// documentResolutions 向解析器要一条消息的结论与正文。未接线解析器时返回空
|
||
// 映射:每份文档仍会渲染出「仅清单」块(§4.2-3 清单恒在,绝不空上下文)。
|
||
func (l *Loop) documentResolutions(ctx context.Context, m model.Message) (map[string]history.DocumentRef, map[string]string, error) {
|
||
if l.documentResolver == nil {
|
||
return nil, nil, nil
|
||
}
|
||
refs, bodies, err := l.documentResolver.Resolve(ctx, m)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("loop: resolve documents: %w", err)
|
||
}
|
||
return refs, bodies, nil
|
||
}
|
||
|
||
// documentResolution 收拢一份文档部件的展示身份与解析结论。身份(存储名、
|
||
// MIME、字节数)一律取自**部件本身**:它由服务端签发,解析器即使填了别的
|
||
// 名字也不参与展示与回传,避免身份被换掉。
|
||
//
|
||
// 状态口径:解析器给出的三态照用;缺失时 —— 解析器整个缺席(功能未接线)算
|
||
// listed(「只给了清单」是事实),而解析器**报了错**(整条消息解析不了)算
|
||
// unreadable(作者「读不到正文」比「只给了清单」更准确,也让失败对客户端可见)。
|
||
//
|
||
// 注入预算(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
|
||
}
|
||
if !documentStatusKnown(ref.Status) {
|
||
ref.Status = history.DocumentStatusListed
|
||
if failed {
|
||
ref.Status = history.DocumentStatusUnreadable
|
||
}
|
||
ref.Chars, ref.Truncated = 0, false
|
||
}
|
||
body := bodies[part.DocumentName]
|
||
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 处理:状态不明时最诚实的说法是「只给了清单」。
|
||
func documentStatusKnown(status string) bool {
|
||
switch status {
|
||
case history.DocumentStatusExtracted, history.DocumentStatusListed, history.DocumentStatusUnreadable:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// documentBlock 渲染一份文档的注入块:清单行**恒在**(名、MIME、大小、结论),
|
||
// 其后是可注入的正文,或读不到时的诚实原因(design/24 §4.2-3、§4.4)。
|
||
func documentBlock(part model.Content, ref history.DocumentRef, body string) string {
|
||
var b strings.Builder
|
||
b.WriteString(documentListPrefix)
|
||
b.WriteString(part.DocumentName)
|
||
b.WriteString(documentListSeparator)
|
||
b.WriteString(documentMIMEOf(part))
|
||
b.WriteString(documentListSeparator)
|
||
b.WriteString(formatDocumentSize(part.DocumentSize))
|
||
b.WriteString(documentListSeparator)
|
||
b.WriteString(documentStatusNote(ref))
|
||
b.WriteString(documentListSuffix)
|
||
if body != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(body)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// documentStatusNote 渲染清单行里的结论部分。截断时给出**续读游标**(已注入
|
||
// 的字符数即抽取正文里的偏移量),使模型与后续的 document-read 工具知道从哪
|
||
// 继续(design/24 §4.3:截断绝不静默)。因为 sidecar 存的是抽取全文
|
||
// (切片 T1 §7.2),这个游标**真的**是「继续读的起点」。
|
||
func documentStatusNote(ref history.DocumentRef) string {
|
||
switch ref.Status {
|
||
case history.DocumentStatusExtracted:
|
||
if ref.Truncated {
|
||
return fmt.Sprintf(documentNoteTruncated, ref.Chars, ref.Chars)
|
||
}
|
||
return fmt.Sprintf(documentNoteExtracted, ref.Chars)
|
||
case history.DocumentStatusUnreadable:
|
||
return documentNoteUnreadable
|
||
default:
|
||
return documentNoteListed
|
||
}
|
||
}
|
||
|
||
// documentMIMEOf 返回清单行里展示的 MIME:缺失时写「未知类型」而不是留空。
|
||
func documentMIMEOf(part model.Content) string {
|
||
if part.DocumentMIME == "" {
|
||
return documentUnknownMIME
|
||
}
|
||
return part.DocumentMIME
|
||
}
|
||
|
||
// formatDocumentSize 把字节数渲染为人类可读的大小,仅用于清单行展示。
|
||
func formatDocumentSize(size int64) string {
|
||
switch {
|
||
case size >= bytesPerMiB:
|
||
return fmt.Sprintf("%.1f MiB", float64(size)/float64(bytesPerMiB))
|
||
case size >= bytesPerKiB:
|
||
return fmt.Sprintf("%.1f KiB", float64(size)/float64(bytesPerKiB))
|
||
case size < 0:
|
||
return fmt.Sprintf("%d B", 0)
|
||
default:
|
||
return fmt.Sprintf("%d B", size)
|
||
}
|
||
}
|
||
|
||
// recordDocuments 把本次装配解析到的结论合并进 Result(按存储名去重、保留首次
|
||
// 出现顺序)。它是「status 回给客户端」的唯一出口:调用方拿 Result.Documents
|
||
// 落进用户消息的 document 列或随响应回传(design/24 §4.4、§6.1)。
|
||
func (r *Result) recordDocuments(refs []history.DocumentRef) {
|
||
for _, ref := range refs {
|
||
if ref.Name == "" {
|
||
continue
|
||
}
|
||
if r.docSeen == nil {
|
||
r.docSeen = make(map[string]int, len(refs))
|
||
}
|
||
if i, ok := r.docSeen[ref.Name]; ok {
|
||
r.Documents[i] = ref
|
||
continue
|
||
}
|
||
r.docSeen[ref.Name] = len(r.Documents)
|
||
r.Documents = append(r.Documents, ref)
|
||
}
|
||
}
|
||
|
||
// messageHasDocument 报告 msg 是否有任何内容部件是文档部件。
|
||
func messageHasDocument(m model.Message) bool {
|
||
return countDocumentParts(m) > 0
|
||
}
|
||
|
||
// countDocumentParts 数一条消息里的文档部件个数(既用于短路,也用于日志)。
|
||
func countDocumentParts(m model.Message) int {
|
||
n := 0
|
||
for _, part := range m.Content {
|
||
if part.Type == model.ContentTypeDocument {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// anyDocumentPart 报告 messages 里有没有文档部件:没有就整体短路,使没有附件
|
||
// 的回合与接线前逐字节一致。
|
||
func anyDocumentPart(messages []model.Message) bool {
|
||
for _, m := range messages {
|
||
if messageHasDocument(m) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|