453 lines
14 KiB
Go
453 lines
14 KiB
Go
package llm
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"time"
|
||
|
||
"tcm-agent/internal/config"
|
||
"tcm-agent/internal/types"
|
||
)
|
||
|
||
// ========================================================================
|
||
// DeepSeek 客户端
|
||
// ========================================================================
|
||
// DeepSeek 提供 OpenAI 兼容协议,因此底层的 HTTP 调用逻辑与 OpenAI 几乎
|
||
// 一致,主要区别在于默认 BaseURL 和模型名称。
|
||
//
|
||
// 适用场景:
|
||
// - 病历生成(推理能力强,中文医学知识丰富)
|
||
// - 处方辅助(逻辑推理链清晰)
|
||
// - 知识问答(性价比高)
|
||
//
|
||
// 支持的能力:
|
||
// - function_calling(工具调用)
|
||
// - streaming(流式输出)
|
||
// - json_mode(结构化输出)
|
||
// - long_context(DeepSeek-V2 支持 128K)
|
||
// ========================================================================
|
||
|
||
// DeepSeekClient DeepSeek 模型客户端
|
||
type DeepSeekClient struct {
|
||
apiKey string // API 密钥
|
||
baseURL string // API 地址(默认 https://api.deepseek.com)
|
||
model string // 模型名称(deepseek-chat / deepseek-reasoner)
|
||
client *http.Client // HTTP 客户端(带超时)
|
||
capabilities map[string]bool // 能力声明
|
||
lastResult *types.ChatResult // 最近一次 Chat 的 token/finish_reason 快照(线程不安全,仅用于单线程场景)
|
||
}
|
||
|
||
// createDeepSeekClient 工厂方法:创建 DeepSeek 客户端
|
||
func createDeepSeekClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||
// 设置默认值
|
||
baseURL := cfg.BaseURL
|
||
if baseURL == "" {
|
||
baseURL = "https://api.deepseek.com"
|
||
}
|
||
model := cfg.Model
|
||
if model == "" {
|
||
model = "deepseek-chat" // 默认用 V3 对话模型
|
||
}
|
||
|
||
client := &DeepSeekClient{
|
||
apiKey: cfg.APIKey,
|
||
baseURL: baseURL,
|
||
model: model,
|
||
client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second},
|
||
capabilities: map[string]bool{
|
||
CapFunctionCalling: true,
|
||
CapStreaming: true,
|
||
CapJSONMode: true,
|
||
CapLongContext: true, // V2 支持 128K
|
||
CapEmbedding: false, // DeepSeek 暂不支持 Embedding
|
||
},
|
||
}
|
||
|
||
// 如果是 reasoner 模型,额外标注强推理能力
|
||
if model == "deepseek-reasoner" {
|
||
client.capabilities["deep_reasoning"] = true
|
||
}
|
||
|
||
log.Printf("[DeepSeek] 初始化完成 | 模型: %s | 地址: %s | 请求: %s", model, baseURL, ResolveChatCompletionsURL(baseURL))
|
||
return client, nil
|
||
}
|
||
|
||
// Name 返回模型名称
|
||
func (c *DeepSeekClient) Name() string {
|
||
return c.model
|
||
}
|
||
|
||
// Provider 返回供应商名称
|
||
func (c *DeepSeekClient) Provider() string {
|
||
return "deepseek"
|
||
}
|
||
|
||
// Supports 查询是否支持某项能力
|
||
func (c *DeepSeekClient) Supports(capability string) bool {
|
||
return c.capabilities[capability]
|
||
}
|
||
|
||
// Chat 发起对话请求(非流式)
|
||
//
|
||
// 内部流程:
|
||
// 1. 将 types.Message 转换为 OpenAI 兼容格式
|
||
// 2. 附加工具定义(Function Calling)
|
||
// 3. 发送 POST 请求到 /chat/completions
|
||
// 4. 解析响应,包装为 *types.Message
|
||
//
|
||
// 本方法使用客户端默认参数(temperature=0.3, max_tokens=4096);
|
||
// 需要运行时覆盖参数请用 ChatWithOpts(实现 OptAwareClient)。
|
||
func (c *DeepSeekClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) {
|
||
return c.ChatWithOpts(ctx, messages, tools, ChatOpts{})
|
||
}
|
||
|
||
// ChatWithOpts 带运行时参数的 Chat(实现 OptAwareClient 接口)
|
||
//
|
||
// 为什么需要 opts:
|
||
// Agent 高级能力(Token 预算、ReAct 多轮)需要按场景动态调整:
|
||
// - max_tokens:每轮调小,给后续轮次留预算
|
||
// - temperature:反思步骤降温,结果更稳定
|
||
func (c *DeepSeekClient) ChatWithOpts(ctx context.Context, messages []types.Message, tools []types.Tool, opts ChatOpts) (*types.Message, error) {
|
||
// ===== 步骤1:转换消息格式 =====
|
||
openAIMsgs := c.convertMessages(messages)
|
||
|
||
// ===== 步骤2:构建请求体(带默认值) =====
|
||
temperature := 0.3 // 医疗场景默认低温度
|
||
if opts.Temperature > 0 {
|
||
temperature = opts.Temperature
|
||
}
|
||
maxTokens := 4096 // 默认输出上限
|
||
if opts.MaxTokens > 0 {
|
||
maxTokens = opts.MaxTokens
|
||
}
|
||
|
||
body := map[string]any{
|
||
"model": c.model,
|
||
"messages": openAIMsgs,
|
||
"temperature": temperature,
|
||
"max_tokens": maxTokens,
|
||
}
|
||
|
||
// 附加工具定义
|
||
if len(tools) > 0 {
|
||
body["tools"] = c.buildToolDefs(tools)
|
||
body["tool_choice"] = "auto" // 让模型自主决定是否调用工具
|
||
}
|
||
|
||
// ===== 步骤3:发送请求 =====
|
||
startedAt := time.Now()
|
||
resp, err := c.doRequest(ctx, body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("[DeepSeek] 请求失败: %w", err)
|
||
}
|
||
|
||
// ===== 步骤4:解析响应(含 usage / finish_reason) =====
|
||
msg, chatResult := c.parseResponseWithMeta(resp)
|
||
if chatResult != nil {
|
||
chatResult.Provider = "deepseek"
|
||
chatResult.Model = c.model
|
||
chatResult.DurationMs = int(time.Since(startedAt).Milliseconds())
|
||
c.lastResult = chatResult
|
||
}
|
||
return msg, nil
|
||
}
|
||
|
||
// LastChatResult 实现 TokenAwareClient 接口
|
||
//
|
||
// 返回最近一次 Chat 调用的 token/finish_reason 快照,供 EnhancerService 写 step 表
|
||
func (c *DeepSeekClient) LastChatResult() *types.ChatResult {
|
||
return c.lastResult
|
||
}
|
||
|
||
// StreamChat 流式对话(逐块输出)
|
||
//
|
||
// 用于前端实时显示模型输出,提升用户体验。
|
||
// 返回 channel,调用方用 for range 消费即可。
|
||
func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []types.Message, tools []types.Tool) (<-chan string, error) {
|
||
openAIMsgs := c.convertMessages(messages)
|
||
|
||
body := map[string]any{
|
||
"model": c.model,
|
||
"messages": openAIMsgs,
|
||
"temperature": 0.3,
|
||
"max_tokens": 4096,
|
||
"stream": true, // 开启流式
|
||
}
|
||
|
||
if len(tools) > 0 {
|
||
body["tools"] = c.buildToolDefs(tools)
|
||
}
|
||
|
||
buf, _ := json.Marshal(body)
|
||
req, _ := http.NewRequestWithContext(ctx, "POST", ResolveChatCompletionsURL(c.baseURL), bytes.NewReader(buf))
|
||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "text/event-stream") // SSE 流
|
||
|
||
resp, err := c.client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("[DeepSeek] 流式请求失败: %w", err)
|
||
}
|
||
|
||
// 解析 SSE 流,逐块发送到 channel
|
||
ch := make(chan string, 10)
|
||
go func() {
|
||
defer resp.Body.Close()
|
||
defer close(ch)
|
||
|
||
// 简单的 SSE 解析(生产环境建议用专门的 SSE 库)
|
||
buf := make([]byte, 4096)
|
||
for {
|
||
n, err := resp.Body.Read(buf)
|
||
if n > 0 {
|
||
// 解析 "data: {...}" 格式
|
||
chunk := string(buf[:n])
|
||
// 这里简化解析,实际应处理完整的 SSE 事件
|
||
ch <- chunk
|
||
}
|
||
if err != nil {
|
||
break
|
||
}
|
||
}
|
||
}()
|
||
|
||
return ch, nil
|
||
}
|
||
|
||
// Embed 生成文本向量
|
||
//
|
||
// 注意:DeepSeek 官方 API 目前不提供 Embedding 服务。
|
||
// 如果配置了 DeepSeek 做 Embedding,会返回错误。
|
||
// 建议使用专门的 Embedding 模型(如 text-embedding-3-small)。
|
||
func (c *DeepSeekClient) Embed(ctx context.Context, texts []string) ([][]float32, error) {
|
||
return nil, fmt.Errorf("[DeepSeek] 该模型不支持 Embedding,请配置专门的 Embedding 模型")
|
||
}
|
||
|
||
// Close 释放资源
|
||
func (c *DeepSeekClient) Close() error {
|
||
c.client.CloseIdleConnections()
|
||
return nil
|
||
}
|
||
|
||
// ========================================================================
|
||
// 内部辅助方法
|
||
// ========================================================================
|
||
|
||
// convertMessages 将内部消息格式转换为 OpenAI 兼容格式
|
||
//
|
||
// 工具调用相关帧遵守 OpenAI Function Calling 协议:
|
||
// - assistant 帧的 tool_calls 带 id,arguments 为 JSON 字符串
|
||
// - tool 结果帧带 tool_call_id 与上面的 id 对应
|
||
// 若 tool 帧缺 tool_call_id(老数据/异常路径),降级为 assistant 角色
|
||
// 保持旧行为兜底(DeepSeek 会拒绝没有 tool_call_id 的 tool 帧)
|
||
func (c *DeepSeekClient) convertMessages(messages []types.Message) []map[string]any {
|
||
openAIMsgs := make([]map[string]any, 0, len(messages))
|
||
for _, m := range messages {
|
||
msg := map[string]any{
|
||
"role": m.Role,
|
||
"content": m.Content,
|
||
}
|
||
// tool 结果帧处理
|
||
if m.Role == "tool" {
|
||
if m.ToolCallID != "" {
|
||
// 协议正确路径:带 tool_call_id 回传
|
||
msg["tool_call_id"] = m.ToolCallID
|
||
} else {
|
||
// 兜底:没有 id 时降级为 assistant(旧 hack,避免被 DeepSeek 拒收)
|
||
msg["role"] = "assistant"
|
||
}
|
||
}
|
||
// assistant 工具调用帧回放(arguments 必须是 JSON 字符串,不能传对象)
|
||
if m.ToolCall != nil {
|
||
args, _ := json.Marshal(m.ToolCall.Params)
|
||
id := m.ToolCall.ID
|
||
if id == "" {
|
||
id = fmt.Sprintf("call_%d", time.Now().UnixNano())
|
||
}
|
||
msg["tool_calls"] = []map[string]any{
|
||
{
|
||
"id": id,
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": m.ToolCall.ToolName,
|
||
"arguments": string(args),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
openAIMsgs = append(openAIMsgs, msg)
|
||
}
|
||
return openAIMsgs
|
||
}
|
||
|
||
// buildToolDefs 构建 OpenAI Function Calling 工具定义
|
||
func (c *DeepSeekClient) buildToolDefs(tools []types.Tool) []map[string]any {
|
||
toolDefs := make([]map[string]any, 0, len(tools))
|
||
for _, t := range tools {
|
||
toolDefs = append(toolDefs, map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": t.Name(),
|
||
"description": t.Description(),
|
||
"parameters": map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"query": map[string]any{
|
||
"type": "string",
|
||
"description": "检索/查询的关键词或问题",
|
||
},
|
||
},
|
||
"required": []string{"query"},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
return toolDefs
|
||
}
|
||
|
||
// doRequest 执行 HTTP 请求
|
||
func (c *DeepSeekClient) doRequest(ctx context.Context, body map[string]any) (map[string]any, error) {
|
||
buf, _ := json.Marshal(body)
|
||
// 兼容 DB 完整路径与 yaml 仅 host,避免 /chat/completions 双重拼接 404
|
||
req, err := http.NewRequestWithContext(ctx, "POST", ResolveChatCompletionsURL(c.baseURL), bytes.NewReader(buf))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := c.client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("网络请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, _ := io.ReadAll(resp.Body)
|
||
|
||
// 检查 HTTP 状态码
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("DeepSeek API 返回 %d: %s", resp.StatusCode, string(data))
|
||
}
|
||
|
||
// 解析 JSON
|
||
var result map[string]any
|
||
if err := json.Unmarshal(data, &result); err != nil {
|
||
return nil, fmt.Errorf("解析响应失败: %w | 原始: %s", err, string(data[:min(len(data), 500)]))
|
||
}
|
||
|
||
// 检查 API 错误
|
||
if errObj, ok := result["error"].(map[string]any); ok {
|
||
msg, _ := errObj["message"].(string)
|
||
return nil, fmt.Errorf("DeepSeek API 错误: %s", msg)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// parseResponse 解析模型响应为 types.Message(保留旧 API 供 StreamChat 复用)
|
||
func (c *DeepSeekClient) parseResponse(data map[string]any) *types.Message {
|
||
msg, _ := c.parseResponseWithMeta(data)
|
||
return msg
|
||
}
|
||
|
||
// parseResponseWithMeta 解析响应并附带 token/finish_reason 等元数据
|
||
//
|
||
// 返回:
|
||
// - *types.Message:assistant 消息(可能含 tool_calls)
|
||
// - *types.ChatResult:token 用量 + finish_reason(写入 lastResult 供 LastChatResult 取)
|
||
//
|
||
// 为什么不修改 parseResponse 签名:
|
||
// parseResponse 被 StreamChat 调用,StreamChat 不需要 token 统计。
|
||
// 新增独立方法避免破坏流式路径。
|
||
func (c *DeepSeekClient) parseResponseWithMeta(data map[string]any) (*types.Message, *types.ChatResult) {
|
||
msg := &types.Message{
|
||
Role: "assistant",
|
||
Timestamp: time.Now().Unix(),
|
||
}
|
||
chatResult := &types.ChatResult{}
|
||
|
||
choices, ok := data["choices"].([]any)
|
||
if !ok || len(choices) == 0 {
|
||
msg.Content = "(模型返回空响应)"
|
||
return msg, chatResult
|
||
}
|
||
|
||
choice, ok := choices[0].(map[string]any)
|
||
if !ok {
|
||
msg.Content = "(无法解析模型响应)"
|
||
return msg, chatResult
|
||
}
|
||
|
||
// 解析 finish_reason(stop/length/content_filter/tool_calls)
|
||
// length 表示被 max_tokens 截断,调用方据此可触发重试或修复
|
||
if fr, ok := choice["finish_reason"].(string); ok {
|
||
chatResult.FinishReason = fr
|
||
}
|
||
|
||
respMsg, ok := choice["message"].(map[string]any)
|
||
if !ok {
|
||
msg.Content = "(响应格式异常)"
|
||
return msg, chatResult
|
||
}
|
||
|
||
// 提取文本内容
|
||
if content, ok := respMsg["content"].(string); ok {
|
||
msg.Content = content
|
||
}
|
||
|
||
// 检查工具调用
|
||
if toolCalls, ok := respMsg["tool_calls"].([]any); ok && len(toolCalls) > 0 {
|
||
tc := toolCalls[0].(map[string]any)
|
||
fn, _ := tc["function"].(map[string]any)
|
||
|
||
name, _ := fn["name"].(string)
|
||
argsStr, _ := fn["arguments"].(string)
|
||
|
||
params := make(map[string]any)
|
||
json.Unmarshal([]byte(argsStr), ¶ms)
|
||
|
||
// id 厂商未返回时生成一个,保证回放帧协议完整(tool_call_id 有对应目标)
|
||
id, _ := tc["id"].(string)
|
||
if id == "" {
|
||
id = fmt.Sprintf("call_%d", time.Now().UnixNano())
|
||
}
|
||
msg.ToolCall = &types.ToolCallInfo{
|
||
ID: id,
|
||
ToolName: name,
|
||
Params: params,
|
||
}
|
||
chatResult.ToolCall = msg.ToolCall
|
||
log.Printf("[DeepSeek] 模型决定调用工具: %s 参数: %v", name, params)
|
||
}
|
||
|
||
// 解析 token 用量(usage 字段)
|
||
// DeepSeek 与 OpenAI 协议一致:prompt_tokens / completion_tokens / total_tokens
|
||
if usage, ok := data["usage"].(map[string]any); ok {
|
||
chatResult.Usage = usage
|
||
if v, ok := usage["prompt_tokens"].(float64); ok {
|
||
chatResult.PromptTokens = int(v)
|
||
}
|
||
if v, ok := usage["completion_tokens"].(float64); ok {
|
||
chatResult.CompletionTokens = int(v)
|
||
}
|
||
if v, ok := usage["total_tokens"].(float64); ok {
|
||
chatResult.TotalTokens = int(v)
|
||
}
|
||
}
|
||
|
||
return msg, chatResult
|
||
}
|
||
|
||
// min 取较小值(Go 1.21 以下没有泛型 min)
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|