- 窗口声明: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)。
231 lines
7.5 KiB
Go
231 lines
7.5 KiB
Go
package model
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
)
|
||
|
||
// openAIToolAcc 累积单个流式工具调用的部分状态(按
|
||
// 提供商的 tool_calls[].index 作键),以便其参数跨
|
||
// 增量片段重新拼装。started 保证每块只发出一次 ToolCallStart。
|
||
type openAIToolAcc struct {
|
||
id string
|
||
name string
|
||
args strings.Builder
|
||
started bool
|
||
}
|
||
|
||
// idOrName 解出用于关联 start/delta/end 的块标识:
|
||
// 有提供商工具调用 id 时用它,否则用函数名。
|
||
func (a *openAIToolAcc) idOrName() string {
|
||
if a.id != "" {
|
||
return a.id
|
||
}
|
||
return a.name
|
||
}
|
||
|
||
// openAIStreamState 是一次流式 /chat/completions 响应的
|
||
// 运行中聚合状态:拼装好的 Response、当前打开片段的
|
||
// 包裹标志,以及按索引的工具调用累加器。它是
|
||
// Anthropic 的 streamBlock 记账的对位实现,因此两个适配器
|
||
// 共用同一套「解析 → 应用 → 收尾」形态。emit 是容忍 nil 的事件槽
|
||
// (onEvent 为 nil 时只是丢弃每个事件)。
|
||
type openAIStreamState struct {
|
||
agg Response
|
||
text strings.Builder
|
||
textStarted bool
|
||
thinkingStarted bool
|
||
toolCalls map[int]*openAIToolAcc
|
||
toolOrder []int
|
||
onEvent func(StreamEvent)
|
||
}
|
||
|
||
// scanSSE 增量解码流块,用 strings.Builder 累积完整正文
|
||
// 文本(绝不一次性读完整个响应体),并从最后一个块
|
||
// 捕获用量。它把每个增量拆到其片段并按对包裹:
|
||
// reasoning_content 作为 thinking_start/delta/end,tool_calls 片段作为
|
||
// tool_call_start/delta/end,正文文本作为 text_start/delta/end。
|
||
// 累积的工具调用参数会跨片段重新拼装成结构化的
|
||
// Args map,并与文本一起挂到返回的 Response 上。
|
||
func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
|
||
scanner := bufio.NewScanner(r)
|
||
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
|
||
st := &openAIStreamState{toolCalls: map[int]*openAIToolAcc{}, onEvent: onEvent}
|
||
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || !strings.HasPrefix(line, sseDataPrefix) {
|
||
continue
|
||
}
|
||
data := strings.TrimSpace(strings.TrimPrefix(line, sseDataPrefix))
|
||
if data == sseDone {
|
||
break
|
||
}
|
||
var chunk openAIChatResponse
|
||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: decode stream chunk: %v", ErrUpstream, err)
|
||
}
|
||
st.applyChunk(chunk)
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: read stream: %v", ErrUpstream, err)
|
||
}
|
||
return finalizeOpenAIStream(st), nil
|
||
}
|
||
|
||
// emit 转发一个结构化事件,容忍 nil 事件槽。
|
||
func (st *openAIStreamState) emit(ev StreamEvent) {
|
||
if st.onEvent != nil {
|
||
st.onEvent(ev)
|
||
}
|
||
}
|
||
|
||
// applyChunk 把单个已解码的流块折叠进运行中的聚合状态:
|
||
// 用量与停止原因即使在无 choice 的终止块上也会被记录,
|
||
// 片段增量(若有)则被路由到其处理器。
|
||
func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
|
||
if u := chunk.Usage; u != nil {
|
||
// 用量块(含缓存的 hit/miss 与思维链 token)只在启用 include_usage 时
|
||
// 随末块到达,且必须原样折进聚合结果 —— 这正是「成本可观测」的落点。
|
||
applyOpenAIUsage(&st.agg, u)
|
||
}
|
||
if len(chunk.Choices) == 0 {
|
||
return
|
||
}
|
||
if chunk.Choices[0].FinishReason != "" {
|
||
st.agg.StopReason = chunk.Choices[0].FinishReason
|
||
}
|
||
if chunk.Choices[0].Delta == nil {
|
||
return
|
||
}
|
||
st.applyDelta(chunk.Choices[0].Delta)
|
||
}
|
||
|
||
// applyDelta 把一个增量片段路由到其片段处理器。优先级
|
||
// 取自提供商:推理片段压过工具调用片段,后者又压过
|
||
// 正文文本,因此一个增量绝不会同时进入两个片段。
|
||
func (st *openAIStreamState) applyDelta(delta *openAIDelta) {
|
||
switch {
|
||
case delta.ReasoningContent != "":
|
||
st.applyThinking(delta.ReasoningContent)
|
||
case len(delta.ToolCalls) > 0:
|
||
st.endThinking()
|
||
for _, tc := range delta.ToolCalls {
|
||
st.applyToolCall(tc)
|
||
}
|
||
case delta.Content != "":
|
||
st.endThinking()
|
||
st.applyText(delta.Content)
|
||
}
|
||
}
|
||
|
||
// applyThinking 转发一个推理片段,只在第一个
|
||
// 片段上打开 thinking 片段。
|
||
func (st *openAIStreamState) applyThinking(fragment string) {
|
||
if !st.thinkingStarted {
|
||
st.thinkingStarted = true
|
||
st.emit(StreamEvent{Kind: StreamKindThinkingStart})
|
||
}
|
||
st.emit(StreamEvent{Kind: StreamKindThinkingDelta, Delta: fragment})
|
||
}
|
||
|
||
// endThinking 关闭已打开的 thinking 片段(未打开时是空操作),
|
||
// 这样随后的工具调用或文本片段会开一个新的。
|
||
func (st *openAIStreamState) endThinking() {
|
||
if !st.thinkingStarted {
|
||
return
|
||
}
|
||
st.thinkingStarted = false
|
||
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
|
||
}
|
||
|
||
// applyToolCall 把一个流式工具调用片段折叠进它按索引的
|
||
// 累加器,在 id/name 已知时打开该块的 start 事件,
|
||
// 并随着参数片段到达而转发它们。
|
||
func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) {
|
||
acc := st.toolCalls[tc.Index]
|
||
if acc == nil {
|
||
acc = &openAIToolAcc{}
|
||
st.toolCalls[tc.Index] = acc
|
||
st.toolOrder = append(st.toolOrder, tc.Index)
|
||
}
|
||
if acc.id == "" {
|
||
acc.id = tc.ID
|
||
}
|
||
if acc.name == "" {
|
||
acc.name = tc.Function.Name
|
||
}
|
||
if !acc.started && acc.idOrName() != "" {
|
||
acc.started = true
|
||
st.emit(StreamEvent{Kind: StreamKindToolCallStart, Index: tc.Index, ID: acc.idOrName()})
|
||
}
|
||
if tc.Function.Arguments == "" {
|
||
return
|
||
}
|
||
acc.args.WriteString(tc.Function.Arguments)
|
||
st.emit(StreamEvent{Kind: StreamKindToolCallDelta, Index: tc.Index, ID: acc.idOrName(), Delta: tc.Function.Arguments})
|
||
}
|
||
|
||
// applyText 追加一个正文文本片段,只在第一个
|
||
// 片段上打开文本片段。
|
||
func (st *openAIStreamState) applyText(fragment string) {
|
||
st.text.WriteString(fragment)
|
||
if !st.textStarted {
|
||
st.textStarted = true
|
||
st.emit(StreamEvent{Kind: StreamKindTextStart})
|
||
}
|
||
st.emit(StreamEvent{Kind: StreamKindTextDelta, Delta: fragment})
|
||
}
|
||
|
||
// closeSegments 在流终止时发出每个仍打开片段的结束
|
||
// 事件。OpenAI 没有按块的停止事件,所以每个已开始的 tool_call
|
||
// 块都在此关闭。
|
||
func (st *openAIStreamState) closeSegments() {
|
||
if st.thinkingStarted {
|
||
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
|
||
}
|
||
if st.textStarted {
|
||
st.emit(StreamEvent{Kind: StreamKindTextEnd})
|
||
}
|
||
for _, idx := range st.toolOrder {
|
||
acc := st.toolCalls[idx]
|
||
if acc == nil || !acc.started {
|
||
continue
|
||
}
|
||
st.emit(StreamEvent{Kind: StreamKindToolCallEnd, Index: idx, ID: acc.idOrName()})
|
||
}
|
||
}
|
||
|
||
// assembleToolCalls 按提供商顺序把每个累积的工具调用参数缓冲
|
||
// 解码成结构化的 Args map。无法解析的片段降级为
|
||
// 空 map(该调用仍会浮出)。
|
||
func (st *openAIStreamState) assembleToolCalls() {
|
||
for _, idx := range st.toolOrder {
|
||
acc := st.toolCalls[idx]
|
||
if acc == nil {
|
||
continue
|
||
}
|
||
args := map[string]any{}
|
||
if acc.args.Len() > 0 {
|
||
if err := json.Unmarshal([]byte(acc.args.String()), &args); err != nil {
|
||
args = map[string]any{}
|
||
}
|
||
}
|
||
st.agg.ToolCalls = append(st.agg.ToolCalls, ToolCall{Name: acc.name, ID: acc.id, Args: args})
|
||
}
|
||
}
|
||
|
||
// finalizeOpenAIStream 关闭打开的片段并拼装聚合后的
|
||
// Response:拼接的正文文本(推理永不泄漏进去)加上
|
||
// 重新拼装的工具调用。
|
||
func finalizeOpenAIStream(st *openAIStreamState) Response {
|
||
st.closeSegments()
|
||
st.assembleToolCalls()
|
||
st.agg.Content = st.text.String()
|
||
st.agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: st.agg.Content}}}
|
||
return st.agg
|
||
}
|