Files
Cyrene_v2/internal/runtime/model/openai_stream.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

231 lines
7.4 KiB
Go
Raw 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.
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 {
st.agg.PromptTokens = u.PromptTokens
st.agg.CompletionTokens = u.CompletionTokens
st.agg.TotalTokens = u.TotalTokens
}
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
}