Files
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)。
2026-10-04 17:57:08 +08:00

317 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// model 包为 Cyrene v2 提供与厂商无关的 LLM 适配层。
//
// 它定义了归一化的 Message/Request/Response 词汇、单个
// 由 OpenAIChatAdapter 实现的 Adapter 接口(该适配器说
// OpenAI、DashScope/百炼等使用的 OpenAI 兼容 /chat/completions 协议、
// 本地网关与代理),还有按角色的 Router、自动降级 Chain、
// 多模态(视觉)检测与按调用的 CallRecord 计账。
//
// 完整的工具循环(schema 注册、参数绑定、结果回喂)
// 推迟到 M2;本包只携带接口级占位
// (ToolSpec),让线格式契约先就绪,而不执行任何循环。
package model
import "time"
// Role 标识消息的作者。取值与厂商无关,
// 由适配器翻译成各提供商自己的角色词汇。
type Role string
const (
// RoleSystem 是系统/指令消息。
RoleSystem Role = "system"
// RoleUser 是终端用户消息。
RoleUser Role = "user"
// RoleAssistant 是助手(模型)消息。
RoleAssistant Role = "assistant"
// RoleTool 是工具结果消息(保留给 M2 工具循环)。
RoleTool Role = "tool"
)
// ContentType 描述 Content 片段内负载的种类。
type ContentType string
const (
// ContentTypeText 是纯文本内容片段。
ContentTypeText ContentType = "text"
// ContentTypeImage 是图片内容片段。
ContentTypeImage ContentType = "image_url"
// ContentTypeToolCall 是助手发起的工具调用片段
// (Anthropic tool_use / OpenAI tool_calls),挂在 RoleAssistant 回合上。
ContentTypeToolCall ContentType = "tool_call"
// ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool
// 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。
ContentTypeToolResult ContentType = "tool_result"
// ContentTypeDocument 是文档内容片段(design/24 §2.2)。文档部件只允许
// 挂在 RoleUser 回合上,且**本期永不抵达编码器**:装配期会把它确定性
// 替换为抽取所得的文本部件或仅清单条目(design/24 §2.3)。
ContentTypeDocument ContentType = "document"
)
// Content 是消息的单个片段,可能是多模态的一部分。
type Content struct {
// Type 区分文本片段与图片 / 工具片段。
Type ContentType
// Text 是 Type == ContentTypeText 时的文本负载,
// 或 Type == ContentTypeToolResult 时的结果正文。
Text string
// ImageURL 是 Type == ContentTypeImage 时的图片来源。它可以是
// data: URI 或 http(s):// URL。
ImageURL string
// ToolCallID 把片段关联到提供商的 tool_use/tool_call id:
// ContentTypeToolCall 对应调用 id,
// ContentTypeToolResult 对应被应答的 tool_use_id。
ToolCallID string
// ToolCallName 是 Type == ContentTypeToolCall 时被调用的工具名。
ToolCallName string
// ToolArgs 是 Type == ContentTypeToolCall 时的结构化参数对象。
// 它绝不是 JSON 编码的字符串。
ToolArgs map[string]any
// IsError 把 ContentTypeToolResult 片段标记为执行失败,
// 以便原生设置提供商的 tool_result.is_error / error 标志。
IsError bool
// DocumentName 是 Type == ContentTypeDocument 时该文档的服务端存储名
// (design/24 §2.2、§3.2)。它由服务端签发,绝不是客户端传来的原始
// 文件名——原始文件名永不参与落盘路径。
DocumentName string
// DocumentMIME 是服务端**字节嗅探**所得的媒体类型(design/24 §3.2:
// 嗅探是唯一裁决),绝不是客户端的声明。
DocumentMIME string
// DocumentURL 是服务端签发的本地引用(design/24 §6.1 的文档读侧
// URL)。任意 http(s) URL 一律不接受:那会重开 F3 那种「服务端代取
// URL」的 SSRF 面。
DocumentURL string
// DocumentSize 是该文档的字节数。
DocumentSize int64
}
// Message 是单个会话回合,已跨提供商归一化。
type Message struct {
Role Role
// Content 携带一个或多个片段。仅文本的消息使用单个
// ContentTypeText 片段。
Content []Content
// SenderName 是消息发送者可读的显示名
// (design/16 §4.6)。它是上下文组装层用来给模型
// 标注第三方消息(如 "[第三方 · 昵称]")的传输元数据,
// 绝不是 channel 层强类型发送者身份的替代品。
// 为空表示发送者未标注(主人,或未识别/未解析的发送者),
// 调用方可以留着不设置。
SenderName string
// ToolCallID 把 RoleTool 回合关联到它所应答的提供商
// tool_use/tool_call id,而 ToolCallName 是同时携带的
// 被调用工具名,方便渲染结果的调用方使用。两者都由工具
// 循环在回喂结果时设置,并被适配器消费以发出
// 原生 tool_result / tool-message 块。
ToolCallID string
ToolCallName string
}
// TextMessage 构建仅文本的消息。
func TextMessage(role Role, text string) Message {
return Message{Role: role, Content: []Content{{Type: ContentTypeText, Text: text}}}
}
// ImageMessage 构建携带可选文本加一张图片的消息。imageURL
// 可以是 data: URI 或 http(s):// URL。文本为空则省略文本片段。
func ImageMessage(role Role, text, imageURL string) Message {
parts := make([]Content, 0, 2)
if text != "" {
parts = append(parts, Content{Type: ContentTypeText, Text: text})
}
parts = append(parts, Content{Type: ContentTypeImage, ImageURL: imageURL})
return Message{Role: role, Content: parts}
}
// ToolResultMessage 构建一个 RoleTool 回合,携带某次工具调用的
// 原生结果。toolUseID 必须等于该结果所应答的 tool_use 块的
// 提供商 id;isError 标记执行失败;text 是结果正文。
func ToolResultMessage(toolUseID, text string, isError bool) Message {
return Message{Role: RoleTool, ToolCallID: toolUseID,
Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}}
}
// AssistantToolCallMessage 构建一个 RoleAssistant 回合,声明模型
// 请求过的工具调用,好让随后一个 RoleTool 回合能被提供商
// 关联回来(OpenAI tool_call_id / Anthropic tool_use_id)。
func AssistantToolCallMessage(calls []ToolCall) Message {
parts := make([]Content, 0, len(calls))
for _, c := range calls {
parts = append(parts, Content{Type: ContentTypeToolCall, ToolCallID: c.ID, ToolCallName: c.Name, ToolArgs: c.Args})
}
return Message{Role: RoleAssistant, Content: parts}
}
// ToolSpec 是工具/函数 schema 的接口级占位。完整
// 工具循环(注册、参数绑定、结果回喂)属于 M2;这里
// 只让请求能在线格式契约里向提供商声明工具。
type ToolSpec struct {
Name string
Description string
// Parameters 是工具参数的 JSON Schema。M2 起填入。
Parameters any
}
// ToolCall 是从提供商的 tool_use 内容块解析出的单个
// 助手发起的工具调用。Args 是结构化参数对象
// (绝不是序列化后的 JSON 字符串),因此工具循环可以直接绑定它,
// 无需重新解析。
type ToolCall struct {
// ID 是提供商的 tool_use/tool_call id,工具循环因此能把
// 产生的 RoleTool 消息关联到确切的那次调用(Anthropic tool_use_id /
// OpenAI tool_call_id)。提供商未发出 id 时为空。
ID string
// Name 是被调用的工具。
Name string
// Args 是工具参数,由提供商的结构化
// tool_use.input 对象解码而来。它绝不是 JSON 编码的字符串。
Args map[string]any
}
// Request 是一次补全请求,已跨提供商归一化。
type Request struct {
// Model 覆盖适配器已配置的默认模型。为空表示
// 使用适配器默认值(或链步骤模型)。
Model string
Messages []Message
// Temperature 是采样温度。nil 表示提供商默认值。
//
// DeepSeek 说明(design/04 §3.2 事实 3 与 4):思考开启期间
// temperature 不生效。frequency_penalty 与 presence_penalty 已被弃用
// ——「传入该参数将不会产生任何效果」——在任何模式下都是如此,
// 这就是 Request 没有对应字段的原因。top_p 同样被刻意不暴露:
// 它只在思考开启时生效,且在那里被夹到 >= 0.95,
// 其它情况下恒为 1.0,所以发送一个配置好的值
// 会在开关的一侧静默失效,
// 在另一侧则成为硬性下限。
Temperature *float64
// MaxTokens 限制生成的 token 数。为零表示提供商默认值。
MaxTokens int
// Purpose 为该次调用打标签,用于 CallRecord 计账(如 "chat")。
Purpose string
// PersonaID 是本回合以哪个角色作答——即
// 裁决 03.4.1 所说的按角色路由的「角色」,与 LLM 消息角色不同。RuntimeLLM
// 读它来挑出该角色的提供商链(models.json 的 personaProviders,即
// 跨协议的按角色路由)。为空表示「无角色绑定」:
// 由生效组的默认链服务该请求,因此所有既有
// 调用方的行为与之前完全一致。
//
// 为什么是请求字段而不是 context 键:该决策发生在
// 本就携带 Request 的适配器边界上,而且这个值是
// 与 Model/Purpose 同类的按调用元数据。角色存在于
// loop 私有的会话环境 context 键里,而 model 包读不到
// 它(model 不得 import loop),所以用 ctx 载体反正也得
// 做同样的写入——只是决策被藏在一个看不见的键后面,
// 且难以跨包断言。
PersonaID string
// Tools 是 M2 函数调用的接口级占位。
Tools []ToolSpec
// ToolChoice 可选地指示提供商如何选择工具。它是一个
// 提供商无关的不透明指令(如 {"type":"auto"} 或 {"type":"tool",
// "name":"x"});为空表示提供商默认值。会原样传给
// 支持它的协议(Anthropic 的 tool_choice)。
//
// DeepSeek 红线(design/04 §3.2 事实 5):思考开启时——即其默认——
// 强制型指令("required" / "any" / 具名工具)会被 400
// 拒绝,因此必须先把思考显式关掉。适配器只对这种组合
// 发 WARN;它们绝不会自行关掉思考。
ToolChoice any
// Thinking 控制提供商的推理预算/开关(design/04 §3.2)。
// nil 表示「不干预」,使用提供商默认值。协议中
// 没有对应映射的提供商会记一条 WARN,而不是静默
// 忽略它。
//
// 「不干预」不等于「关闭」:DeepSeek 的思考默认
// 开启,所以在那里要靠 Enable=false 来关(发送为
// {"thinking":{"type":"disabled"}})。
Thinking *Thinking
// ReasoningEffort 是 OpenAI 兼容的推理强度旋钮
// (reasoning_effort)。为空表示「不干预」。该值会针对
// 已识别提供商的白名单校验;无法识别的值会带着 WARN
// 被丢弃,绝不会被换成别的档位(design/04 §3.2 事实 1)。
// DeepSeek 另外记录了兼容别名(minimal→low、
// medium/xhigh→high、ultra→max),它们会被归一化为服务
// 实际采用的档位。
ReasoningEffort string
}
// Thinking 携带调用方希望生效的推理旋钮(design/04 §3.2)。
// 提供商映射它们真正支持的字段、并上报其余字段;它只
// 控制预算/开关,绝不控制思考内容(design/01 §2.1 红线)。
type Thinking struct {
// BudgetTokens 是 Anthropic thinking.budget_tokens 的目标值(官方
// 语义是目标,不是硬上限)。为零则选用适配器默认值。
BudgetTokens int
// Enable 是显式的开/关开关,供暴露布尔
// thinking 标志的提供商使用。nil = 无显式开关。
Enable *bool
}
// Response 是归一化的补全结果。
type Response struct {
// Model 是产出该响应的模型。
Model string
Content string
// Message 是归一化的助手消息。
Message Message
// ToolCalls 携带助手发出的任何 tool_use 块。响应是纯文本时为
// 空。
ToolCalls []ToolCall
// StopReason 是提供商原始的停止/结束原因(Anthropic stop_reason:
// end_turn / max_tokens / tool_use;OpenAI finish_reason:stop / length /
// tool_calls),因此调用方能区分 token 预算截断与
// 正常结束。提供商未上报时为空。
StopReason string
// PromptTokens、CompletionTokens 与 TotalTokens 在可用时上报
// 提供商用量;提供商未返回时为零。
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
// 监控/前端使用。失败的尝试不产生记录。
type CallRecord struct {
// Model 是服务该请求的模型。
Model string
// Purpose 标注该次调用的用途。
Purpose string
// Tokens 是提供商上报的总 token 消耗。
Tokens int
// TookMs 是端到端调用延迟,单位毫秒。
TookMs int64
}
// Options 携带叠加在 Request 之上的按调用旋钮。
type Options struct {
// Timeout 约束单次 HTTP 调用。为零则用适配器客户端的超时。
Timeout time.Duration
}