- 窗口声明: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)。
317 lines
14 KiB
Go
317 lines
14 KiB
Go
// 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
|
||
}
|