refactor: 清理旧编排代码,新增 eino 包单元测试
删除旧代码: - orchestrator/pipeline.go: 旧 STT→LLM→TTS 手写 goroutine 管道 - orchestrator/splitter.go: 旧句子切分器 - orchestrator/pipeline_test.go: 旧 Pipeline 测试 - ai/llm/openai.go: 旧 LLM OpenAI 实现(被 eino-ext ChatModel 替代) - ai/llm/openai_test.go: 旧 LLM 测试 保留的接口和工具: - orchestrator/orchestrator.go: Orchestrator 接口(ws/handler 依赖) - orchestrator/sender.go: Sender 接口(eino/callback 依赖) - ai/llm/llm.go: Request/Chunk/TokenUsage 类型定义 - ai/llm/prompt.go: BuildSystemPrompt(eino/nodes_history 依赖) - ai/llm/scenarios.go: GetScenarioPrompt(eino/nodes_history 依赖) 新增测试: - eino/graph_test.go: 13 个测试覆盖类型构建、State 并发安全、 Context 注入、延迟计算、接口实现检查等 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,239 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// OpenAIService 基于 OpenAI Chat Completions API 的 LLM 实现。
|
||||
type OpenAIService struct {
|
||||
apiKey string
|
||||
model string
|
||||
endpoint string
|
||||
timeout time.Duration
|
||||
logger *zap.SugaredLogger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewOpenAIService 创建 OpenAI LLM 服务。
|
||||
// model、endpoint 由 config 层保证非空。
|
||||
func NewOpenAIService(apiKey, model, endpoint string, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *OpenAIService {
|
||||
timeout := time.Duration(timeoutSec) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second
|
||||
if httpClientTimeout <= 0 {
|
||||
httpClientTimeout = 60 * time.Second
|
||||
}
|
||||
return &OpenAIService{
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
endpoint: endpoint,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: httpClientTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// --- OpenAI API 请求/响应结构 ---
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content []contentPart `json:"content"`
|
||||
}
|
||||
|
||||
type contentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ImageURL *imageURL `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type imageURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// streamDelta SSE 流式响应的单个 delta。
|
||||
type streamDelta struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// ChatStream 实现 llm.Service。调用 OpenAI Chat Completions API 流式推理。
|
||||
func (o *OpenAIService) ChatStream(ctx context.Context, req Request) (<-chan Chunk, error) {
|
||||
// 构建请求
|
||||
messages := o.buildMessages(req)
|
||||
|
||||
body := chatRequest{
|
||||
Model: o.model,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
||||
}
|
||||
|
||||
// 创建带超时的 context
|
||||
ctx, cancel := context.WithTimeout(ctx, o.timeout)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, o.endpoint+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("llm: create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+o.apiKey)
|
||||
|
||||
resp, err := o.client.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("llm: send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
cancel()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("llm: api error (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// 启动 goroutine 解析 SSE 流
|
||||
ch := make(chan Chunk, 64)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer cancel()
|
||||
defer resp.Body.Close()
|
||||
|
||||
o.parseSSEStream(resp.Body, ch)
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// parseSSEStream 解析 SSE 流,将 delta 发送到 channel。
|
||||
func (o *OpenAIService) parseSSEStream(body io.Reader, ch chan<- Chunk) {
|
||||
scanner := bufio.NewScanner(body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||
|
||||
var fullText strings.Builder
|
||||
var lastModel string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// SSE 格式:data: {...}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
// 流结束,发送最终 chunk
|
||||
ch <- Chunk{Delta: "", Done: true, Model: lastModel}
|
||||
return
|
||||
}
|
||||
|
||||
var delta streamDelta
|
||||
if err := json.Unmarshal([]byte(data), &delta); err != nil {
|
||||
o.logger.Warnw("llm: unmarshal delta failed", "error", err, "data", data)
|
||||
continue
|
||||
}
|
||||
|
||||
if delta.Model != "" {
|
||||
lastModel = delta.Model
|
||||
}
|
||||
|
||||
// 提取增量文本
|
||||
if len(delta.Choices) > 0 {
|
||||
content := delta.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
fullText.WriteString(content)
|
||||
ch <- Chunk{Delta: content, Done: false, Model: lastModel}
|
||||
}
|
||||
|
||||
// 某些模型在最后一个 choice 中携带 usage
|
||||
if delta.Choices[0].FinishReason != nil && delta.Usage != nil {
|
||||
ch <- Chunk{
|
||||
Delta: "",
|
||||
Done: true,
|
||||
Model: lastModel,
|
||||
TokensUsed: &TokenUsage{
|
||||
Prompt: delta.Usage.PromptTokens,
|
||||
Completion: delta.Usage.CompletionTokens,
|
||||
Total: delta.Usage.TotalTokens,
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanner 结束但没收到 [DONE]
|
||||
if err := scanner.Err(); err != nil {
|
||||
o.logger.Warnw("llm: scan error", "error", err)
|
||||
}
|
||||
ch <- Chunk{Delta: "", Done: true, Model: lastModel}
|
||||
}
|
||||
|
||||
// buildMessages 构建 OpenAI Chat API 的 messages 数组。
|
||||
func (o *OpenAIService) buildMessages(req Request) []chatMessage {
|
||||
var messages []chatMessage
|
||||
|
||||
// System prompt(情景覆盖优先)
|
||||
messages = append(messages, chatMessage{
|
||||
Role: "system",
|
||||
Content: []contentPart{{Type: "text", Text: BuildSystemPrompt(req.Language, "", req.SystemPrompt)}},
|
||||
})
|
||||
|
||||
// 历史消息
|
||||
for _, msg := range req.History {
|
||||
messages = append(messages, chatMessage{
|
||||
Role: msg.Role,
|
||||
Content: []contentPart{{Type: "text", Text: msg.Content}},
|
||||
})
|
||||
}
|
||||
|
||||
// 当前用户消息(图像 + 文本)
|
||||
var parts []contentPart
|
||||
if len(req.Image) > 0 {
|
||||
b64 := base64.StdEncoding.EncodeToString(req.Image)
|
||||
parts = append(parts, contentPart{
|
||||
Type: "image_url",
|
||||
ImageURL: &imageURL{URL: "data:image/jpeg;base64," + b64},
|
||||
})
|
||||
}
|
||||
parts = append(parts, contentPart{Type: "text", Text: req.Text})
|
||||
messages = append(messages, chatMessage{Role: "user", Content: parts})
|
||||
|
||||
return messages
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
// mockLLMServer 创建模拟 OpenAI SSE 流式响应的 HTTP 服务器。
|
||||
func mockLLMServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func TestOpenAIService_ChatStream_Success(t *testing.T) {
|
||||
srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// 验证请求
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if !strings.Contains(r.URL.Path, "/chat/completions") {
|
||||
t.Errorf("path = %s, should contain /chat/completions", r.URL.Path)
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-key" {
|
||||
t.Errorf("Authorization = %q, want %q", auth, "Bearer test-key")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("ResponseWriter does not support Flusher")
|
||||
}
|
||||
|
||||
// 发送几个 delta
|
||||
deltas := []string{"你好", "世界", "!"}
|
||||
for _, d := range deltas {
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"%s\"}}],\"model\":\"gpt-4o\"}\n\n", d)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// 发送 [DONE]
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||
|
||||
ch, err := svc.ChatStream(context.Background(), Request{
|
||||
Text: "这是什么?",
|
||||
Language: "zh-CN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error: %v", err)
|
||||
}
|
||||
|
||||
var chunks []Chunk
|
||||
for c := range ch {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 应该有 3 个文本 chunk + 1 个 Done chunk
|
||||
if len(chunks) != 4 {
|
||||
t.Fatalf("got %d chunks, want 4", len(chunks))
|
||||
}
|
||||
|
||||
// 验证文本内容
|
||||
if chunks[0].Delta != "你好" {
|
||||
t.Errorf("chunk[0].Delta = %q, want %q", chunks[0].Delta, "你好")
|
||||
}
|
||||
if chunks[1].Delta != "世界" {
|
||||
t.Errorf("chunk[1].Delta = %q, want %q", chunks[1].Delta, "世界")
|
||||
}
|
||||
|
||||
// 验证最后一个 chunk 是 Done
|
||||
last := chunks[len(chunks)-1]
|
||||
if !last.Done {
|
||||
t.Error("last chunk should be Done")
|
||||
}
|
||||
if last.Model != "gpt-4o" {
|
||||
t.Errorf("last chunk Model = %q, want %q", last.Model, "gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIService_ChatStream_WithImage(t *testing.T) {
|
||||
srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}],\"model\":\"gpt-4o\"}\n\n")
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||
|
||||
ch, err := svc.ChatStream(context.Background(), Request{
|
||||
Image: []byte("fake-jpeg-data"),
|
||||
Text: "描述图片",
|
||||
Language: "zh-CN",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error: %v", err)
|
||||
}
|
||||
|
||||
// 消费 channel
|
||||
for range ch {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIService_ChatStream_WithHistory(t *testing.T) {
|
||||
srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}],\"model\":\"gpt-4o\"}\n\n")
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||
|
||||
ch, err := svc.ChatStream(context.Background(), Request{
|
||||
Text: "继续",
|
||||
Language: "zh-CN",
|
||||
History: []models.Message{
|
||||
{Role: "user", Content: "你好"},
|
||||
{Role: "assistant", Content: "你好!有什么可以帮助你的吗?"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error: %v", err)
|
||||
}
|
||||
|
||||
for range ch {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIService_ChatStream_APIError(t *testing.T) {
|
||||
srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":{"message":"Invalid API key"}}`)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIService("bad-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||
|
||||
_, err := svc.ChatStream(context.Background(), Request{
|
||||
Text: "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ChatStream() should return error for 401")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "401") {
|
||||
t.Errorf("error should mention 401, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIService_ChatStream_Timeout(t *testing.T) {
|
||||
srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// 模拟慢响应
|
||||
time.Sleep(5 * time.Second)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n")
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 1, 60, zap.NewNop().Sugar()) // 1s timeout
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := svc.ChatStream(ctx, Request{Text: "test"})
|
||||
if err != nil {
|
||||
// 超时可能在建立连接时或读取时发生
|
||||
return
|
||||
}
|
||||
|
||||
// 如果连接成功,消费 channel 应该超时
|
||||
var gotContent bool
|
||||
for c := range ch {
|
||||
if c.Delta != "" {
|
||||
gotContent = true
|
||||
}
|
||||
}
|
||||
if gotContent {
|
||||
t.Error("should not receive content before timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIService_ChatStream_UsageInResponse(t *testing.T) {
|
||||
srv := mockLLMServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
// 带 usage 的最后一个 chunk
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}],\"model\":\"gpt-4o\",\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n")
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar())
|
||||
|
||||
ch, err := svc.ChatStream(context.Background(), Request{Text: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream() error: %v", err)
|
||||
}
|
||||
|
||||
var last Chunk
|
||||
for c := range ch {
|
||||
last = c
|
||||
}
|
||||
|
||||
if !last.Done {
|
||||
t.Error("last chunk should be Done")
|
||||
}
|
||||
if last.TokensUsed == nil {
|
||||
t.Fatal("last chunk should have TokensUsed")
|
||||
}
|
||||
if last.TokensUsed.Total != 15 {
|
||||
t.Errorf("TokensUsed.Total = %d, want 15", last.TokensUsed.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
language string
|
||||
detailLevel string
|
||||
wantContain string
|
||||
}{
|
||||
{"chinese default", "zh-CN", "", "视觉助手"},
|
||||
{"chinese high", "zh-CN", "high", "更详细"},
|
||||
{"english default", "en", "", "visual assistant"},
|
||||
{"english high", "en", "high", "detailed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := BuildSystemPrompt(tt.language, tt.detailLevel, "")
|
||||
if !strings.Contains(got, tt.wantContain) {
|
||||
t.Errorf("BuildSystemPrompt(%q, %q, \"\") should contain %q", tt.language, tt.detailLevel, tt.wantContain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user