feat(llm): 实现阶段 3 LLM 集成 — OpenAI 客户端与 ChatModel 适配器
All checks were successful
GoLoom CI / Lint (push) Successful in 21m3s
GoLoom CI / Test (push) Successful in 9m20s
GoLoom CI / Build (push) Successful in 9m27s

This commit is contained in:
hhs
2026-06-10 13:46:38 +08:00
parent 85a3d1e27d
commit 9d04b0b200
3 changed files with 402 additions and 6 deletions

View File

@@ -1 +1,76 @@
package llm
import (
"context"
"fmt"
"strings"
"ai-agent-scaffold-go/internal/model"
)
// ChatModelAdapter 适配器,将 OpenAIClient 包装为 model.ChatModel
type ChatModelAdapter struct {
client *OpenAIClient
tools []model.Tool
}
// NewChatModelAdapter 创建 ChatModel 适配器
func NewChatModelAdapter(client *OpenAIClient, tools []model.Tool) *ChatModelAdapter {
return &ChatModelAdapter{client: client, tools: tools}
}
// Generate 实现 model.ChatModel 接口
func (m *ChatModelAdapter) Generate(ctx context.Context, messages []model.ChatMessage) (model.ChatReply, error) {
return m.client.Generate(ctx, messages, m.toolDefs())
}
// Stream 实现 model.ChatModel 接口
func (m *ChatModelAdapter) Stream(ctx context.Context, messages []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
return m.client.Stream(ctx, messages, m.toolDefs())
}
// Tools 返回注册的工具列表
func (m *ChatModelAdapter) Tools() []model.Tool {
return m.tools
}
// CallTool 根据名称和参数调用对应的工具
func (m *ChatModelAdapter) CallTool(ctx context.Context, name, arguments string) (string, error) {
query := extractQuery(arguments)
for _, t := range m.tools {
if t.Name() == name {
return t.Call(ctx, query)
}
}
return "", fmt.Errorf("tool %q not found", name)
}
// toolDefs 将 model.Tool 转换为 ToolDef 列表
func (m *ChatModelAdapter) toolDefs() []ToolDef {
defs := make([]ToolDef, 0, len(m.tools))
for _, t := range m.tools {
defs = append(defs, ToolDef{Name: t.Name(), Description: t.Description()})
}
return defs
}
// extractQuery 从工具调用参数 JSON 中提取 query 字段
func extractQuery(arguments string) string {
arguments = strings.TrimSpace(arguments)
if arguments == "" {
return ""
}
if idx := strings.Index(arguments, `"query"`); idx >= 0 {
rest := arguments[idx+7:]
if colon := strings.Index(rest, `:`); colon >= 0 {
rest = strings.TrimSpace(rest[colon+1:])
if strings.HasPrefix(rest, `"`) {
rest = rest[1:]
if end := strings.Index(rest, `"`); end >= 0 {
return rest[:end]
}
}
}
}
return arguments
}

View File

@@ -1 +1,328 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"ai-agent-scaffold-go/internal/model"
)
// OpenAIClient OpenAI 兼容 API 客户端
type OpenAIClient struct {
httpClient *http.Client
completionsURL string
apiKey string
model string
}
// ToolDef 工具定义,用于传给 LLM 的 tools 参数
type ToolDef struct {
Name string
Description string
}
// NewOpenAIClient 创建 OpenAI 客户端
func NewOpenAIClient(completionsURL, apiKey, model string, requestTimeout time.Duration) *OpenAIClient {
if requestTimeout <= 0 {
requestTimeout = 5 * time.Minute
}
return &OpenAIClient{
httpClient: &http.Client{Timeout: requestTimeout},
completionsURL: completionsURL,
apiKey: apiKey,
model: model,
}
}
// Generate 同步调用 LLM返回完整回复
func (c *OpenAIClient) Generate(ctx context.Context, messages []model.ChatMessage, tools []ToolDef) (model.ChatReply, error) {
body, err := buildRequestBody(c.model, messages, tools, false)
if err != nil {
return model.ChatReply{}, err
}
resp, err := c.do(ctx, body)
if err != nil {
return model.ChatReply{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return model.ChatReply{}, fmt.Errorf("openai read body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return model.ChatReply{}, fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
}
var parsed openaiCompletion
if err := json.Unmarshal(raw, &parsed); err != nil {
return model.ChatReply{}, fmt.Errorf("openai decode: %w", err)
}
if len(parsed.Choices) == 0 {
return model.ChatReply{}, fmt.Errorf("openai response has no choices")
}
choice := parsed.Choices[0].Message
reply := model.ChatReply{Content: choice.Content}
for _, tc := range choice.ToolCalls {
reply.ToolCalls = append(reply.ToolCalls, model.ChatToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
return reply, nil
}
// Stream 流式调用 LLM返回事件通道和错误通道
func (c *OpenAIClient) Stream(ctx context.Context, messages []model.ChatMessage, tools []ToolDef) (<-chan model.ChatStreamEvent, <-chan error) {
events := make(chan model.ChatStreamEvent, 8)
errs := make(chan error, 1)
go func() {
defer close(events)
defer close(errs)
body, err := buildRequestBody(c.model, messages, tools, true)
if err != nil {
errs <- err
return
}
resp, err := c.do(ctx, body)
if err != nil {
errs <- err
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
errs <- fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
return
}
toolCallBuf := map[int]*model.ChatToolCall{}
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
emitToolCalls(events, toolCallBuf)
events <- model.ChatStreamEvent{Done: true}
return
}
errs <- fmt.Errorf("openai stream read: %w", err)
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
emitToolCalls(events, toolCallBuf)
events <- model.ChatStreamEvent{Done: true}
return
}
var chunk openaiStreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
errs <- fmt.Errorf("openai stream decode: %w", err)
return
}
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta
if delta.Content != "" {
select {
case events <- model.ChatStreamEvent{Delta: delta.Content}:
case <-ctx.Done():
errs <- ctx.Err()
return
}
}
for _, tc := range delta.ToolCalls {
current, ok := toolCallBuf[tc.Index]
if !ok {
current = &model.ChatToolCall{}
toolCallBuf[tc.Index] = current
}
if tc.ID != "" {
current.ID = tc.ID
}
if tc.Function.Name != "" {
current.Name = tc.Function.Name
}
if tc.Function.Arguments != "" {
current.Arguments += tc.Function.Arguments
}
}
}
}()
return events, errs
}
// emitToolCalls 将缓冲区中的工具调用合并发送
func emitToolCalls(events chan<- model.ChatStreamEvent, buf map[int]*model.ChatToolCall) {
if len(buf) == 0 {
return
}
calls := make([]model.ChatToolCall, 0, len(buf))
for i := 0; i < len(buf); i++ {
if call, ok := buf[i]; ok && call != nil {
calls = append(calls, *call)
}
}
if len(calls) > 0 {
events <- model.ChatStreamEvent{ToolCalls: calls}
}
}
// do 发送 HTTP 请求
func (c *OpenAIClient) do(ctx context.Context, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.completionsURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("openai build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
return c.httpClient.Do(req)
}
// buildRequestBody 构建请求体 JSON
func buildRequestBody(modelName string, messages []model.ChatMessage, tools []ToolDef, stream bool) ([]byte, error) {
payload := map[string]any{
"model": modelName,
"messages": encodeMessages(messages),
"stream": stream,
}
if len(tools) > 0 {
payload["tools"] = encodeTools(tools)
}
return json.Marshal(payload)
}
// encodeMessages 将 ChatMessage 转换为 OpenAI API 格式
func encodeMessages(messages []model.ChatMessage) []map[string]any {
encoded := make([]map[string]any, 0, len(messages))
for _, m := range messages {
entry := map[string]any{"role": string(m.Role)}
if m.Content != "" {
entry["content"] = m.Content
} else if m.Role != model.ChatRoleAssistant || len(m.ToolCalls) == 0 {
entry["content"] = ""
}
if m.Name != "" {
entry["name"] = m.Name
}
if m.ToolCallID != "" {
entry["tool_call_id"] = m.ToolCallID
}
if len(m.ToolCalls) > 0 {
calls := make([]map[string]any, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
calls = append(calls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Name,
"arguments": tc.Arguments,
},
})
}
entry["tool_calls"] = calls
}
encoded = append(encoded, entry)
}
return encoded
}
// encodeTools 将工具定义转换为 OpenAI API 格式
func encodeTools(tools []ToolDef) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
desc := t.Description
if desc == "" {
desc = "external tool " + t.Name
}
out = append(out, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": desc,
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "text input for the tool",
},
},
},
},
})
}
return out
}
// truncate 截断字符串
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
// ============================================================
// OpenAI API 响应结构体
// ============================================================
type openaiCompletion struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []openaiToolCallV1 `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
type openaiStreamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []openaiStreamToolCall `json:"tool_calls"`
} `json:"delta"`
} `json:"choices"`
}
type openaiToolCallV1 struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type openaiStreamToolCall struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}