Files
xk-ai-agent/internal/llm/deepseek.go
2026-08-15 17:05:22 +08:00

453 lines
14 KiB
Go
Raw Permalink 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 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_contextDeepSeek-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 带 idarguments 为 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.Messageassistant 消息(可能含 tool_calls
// - *types.ChatResulttoken 用量 + 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_reasonstop/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), &params)
// 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
}