修复deepseek无法对话的bug
This commit is contained in:
@@ -43,7 +43,16 @@ type AIGenerationRow struct {
|
||||
ErrorMsg string `gorm:"column:error_msg" json:"error_msg"`
|
||||
InputSnapshot string `gorm:"column:input_snapshot" json:"input_snapshot,omitempty"`
|
||||
ResultJSON string `gorm:"column:result_json" json:"result_json,omitempty"`
|
||||
CreatedAt int `gorm:"column:created_at" json:"created_at"`
|
||||
// 人工打分 / 归档(超管在 xk-admin 写入,本端只读展示)
|
||||
IsCorrect int `gorm:"column:is_correct" json:"is_correct"`
|
||||
QualityScore int `gorm:"column:quality_score" json:"quality_score"`
|
||||
ReviewRemark string `gorm:"column:review_remark" json:"review_remark"`
|
||||
ReviewedBy int `gorm:"column:reviewed_by" json:"reviewed_by"`
|
||||
ReviewedAt int `gorm:"column:reviewed_at" json:"reviewed_at"`
|
||||
IsArchived int `gorm:"column:is_archived" json:"is_archived"`
|
||||
ArchivedAt int `gorm:"column:archived_at" json:"archived_at"`
|
||||
EvalCaseID int `gorm:"column:eval_case_id" json:"eval_case_id"`
|
||||
CreatedAt int `gorm:"column:created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -75,16 +84,18 @@ func (AIGenerationStepRow) TableName() string { return "xk_ai_generation_step" }
|
||||
|
||||
// AIGenerationListFilter 历史列表的筛选条件
|
||||
//
|
||||
// Status / ViaAgent 用 -1 表示"不筛选"(0 是合法业务值,不能当哨兵)
|
||||
// Status / ViaAgent / IsArchived / IsCorrect 用 -1 表示"不筛选"(0 是合法业务值,不能当哨兵)
|
||||
type AIGenerationListFilter struct {
|
||||
Page int // 页码(从 1 开始)
|
||||
Size int // 每页条数(1~100)
|
||||
Scene string // 场景精确匹配,空=全部
|
||||
Status int // -1=全部 0进行中 1成功 2失败
|
||||
ViaAgent int // -1=全部 0=PHP直连 1=Go Agent
|
||||
Provider string // 供应商精确匹配,空=全部
|
||||
DateStart int64 // created_at >= 起始时间戳,0=不限
|
||||
DateEnd int64 // created_at <= 截止时间戳,0=不限
|
||||
Page int // 页码(从 1 开始)
|
||||
Size int // 每页条数(1~100)
|
||||
Scene string // 场景精确匹配,空=全部
|
||||
Status int // -1=全部 0进行中 1成功 2失败
|
||||
ViaAgent int // -1=全部 0=PHP直连 1=Go Agent
|
||||
Provider string // 供应商精确匹配,空=全部
|
||||
IsArchived int // -1=全部 0未归档 1已归档
|
||||
IsCorrect int // -1=全部 0未评 1正确 2不正确
|
||||
DateStart int64 // created_at >= 起始时间戳,0=不限
|
||||
DateEnd int64 // created_at <= 截止时间戳,0=不限
|
||||
}
|
||||
|
||||
// AIGenerationList 分页查询生成历史(倒序)
|
||||
@@ -115,6 +126,12 @@ func AIGenerationList(f AIGenerationListFilter) ([]AIGenerationRow, int64, error
|
||||
if f.Provider != "" {
|
||||
q = q.Where("provider = ?", f.Provider)
|
||||
}
|
||||
if f.IsArchived >= 0 {
|
||||
q = q.Where("is_archived = ?", f.IsArchived)
|
||||
}
|
||||
if f.IsCorrect >= 0 {
|
||||
q = q.Where("is_correct = ?", f.IsCorrect)
|
||||
}
|
||||
if f.DateStart > 0 {
|
||||
q = q.Where("created_at >= ?", f.DateStart)
|
||||
}
|
||||
@@ -128,7 +145,7 @@ func AIGenerationList(f AIGenerationListFilter) ([]AIGenerationRow, int64, error
|
||||
}
|
||||
|
||||
var rows []AIGenerationRow
|
||||
err := q.Select("id, store_id, register_id, doctor_id, scene, prescription_type, name, provider, model, status, api_key_id, via_agent, step_count, prompt_tokens, completion_tokens, total_tokens, started_at, finished_at, duration_ms, error_msg, created_at").
|
||||
err := q.Select("id, store_id, register_id, doctor_id, scene, prescription_type, name, provider, model, status, api_key_id, via_agent, step_count, prompt_tokens, completion_tokens, total_tokens, started_at, finished_at, duration_ms, error_msg, is_correct, quality_score, review_remark, reviewed_by, reviewed_at, is_archived, archived_at, eval_case_id, created_at").
|
||||
Order("id DESC").
|
||||
Offset((f.Page - 1) * f.Size).
|
||||
Limit(f.Size).
|
||||
|
||||
@@ -48,7 +48,9 @@ func NewEnhancerHandler(svc *service.EnhancerService) *EnhancerHandler {
|
||||
// {
|
||||
// "code": 200,
|
||||
// "data": {
|
||||
// "content": "...",
|
||||
// "content": "...", // 第一份成功(兼容)
|
||||
// "contents": ["..."], // 仅成功内容
|
||||
// "results": [{index,ok,content,error,steps}], // 多份时按槽位
|
||||
// "provider": "spark",
|
||||
// "model": "spark-max",
|
||||
// "steps": [...],
|
||||
|
||||
@@ -31,15 +31,17 @@ func NewHistoryHandler() *HistoryHandler {
|
||||
}
|
||||
|
||||
// List 分页查询历史
|
||||
// GET /api/v1/agent/history?page=1&size=20&scene=&status=-1&via_agent=-1&provider=&date_start=0&date_end=0
|
||||
// GET /api/v1/agent/history?page=1&size=20&scene=&status=-1&via_agent=-1&is_archived=-1&is_correct=-1&provider=&date_start=0&date_end=0
|
||||
func (h *HistoryHandler) List(c *gin.Context) {
|
||||
filter := dao.AIGenerationListFilter{
|
||||
Page: queryInt(c, "page", 1),
|
||||
Size: queryInt(c, "size", 20),
|
||||
Scene: c.Query("scene"),
|
||||
Status: queryInt(c, "status", -1),
|
||||
ViaAgent: queryInt(c, "via_agent", -1),
|
||||
Provider: c.Query("provider"),
|
||||
Page: queryInt(c, "page", 1),
|
||||
Size: queryInt(c, "size", 20),
|
||||
Scene: c.Query("scene"),
|
||||
Status: queryInt(c, "status", -1),
|
||||
ViaAgent: queryInt(c, "via_agent", -1),
|
||||
Provider: c.Query("provider"),
|
||||
IsArchived: queryInt(c, "is_archived", -1),
|
||||
IsCorrect: queryInt(c, "is_correct", -1),
|
||||
}
|
||||
filter.DateStart = int64(queryInt(c, "date_start", 0))
|
||||
filter.DateEnd = int64(queryInt(c, "date_end", 0))
|
||||
|
||||
@@ -34,12 +34,12 @@ import (
|
||||
|
||||
// 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 快照(线程不安全,仅用于单线程场景)
|
||||
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 客户端
|
||||
@@ -73,7 +73,7 @@ func createDeepSeekClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
client.capabilities["deep_reasoning"] = true
|
||||
}
|
||||
|
||||
log.Printf("[DeepSeek] 初始化完成 | 模型: %s | 地址: %s", model, baseURL)
|
||||
log.Printf("[DeepSeek] 初始化完成 | 模型: %s | 地址: %s | 请求: %s", model, baseURL, ResolveChatCompletionsURL(baseURL))
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []types.Messag
|
||||
}
|
||||
|
||||
buf, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(buf))
|
||||
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 流
|
||||
@@ -313,7 +313,8 @@ func (c *DeepSeekClient) buildToolDefs(tools []types.Tool) []map[string]any {
|
||||
// doRequest 执行 HTTP 请求
|
||||
func (c *DeepSeekClient) doRequest(ctx context.Context, body map[string]any) (map[string]any, error) {
|
||||
buf, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(buf))
|
||||
// 兼容 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)
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ import (
|
||||
|
||||
// OpenAIClient OpenAI 模型客户端
|
||||
type OpenAIClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
embeddingModel string
|
||||
client *http.Client
|
||||
capabilities map[string]bool
|
||||
client *http.Client
|
||||
capabilities map[string]bool
|
||||
}
|
||||
|
||||
// createOpenAIClient 工厂方法:创建 OpenAI 客户端
|
||||
@@ -54,11 +54,11 @@ func createOpenAIClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
isReasoningModel := len(model) >= 2 && model[:2] == "o1"
|
||||
|
||||
client := &OpenAIClient{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
embeddingModel: cfg.EmbeddingModel,
|
||||
client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second},
|
||||
client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second},
|
||||
capabilities: map[string]bool{
|
||||
CapFunctionCalling: true,
|
||||
CapStreaming: true,
|
||||
@@ -163,7 +163,7 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []types.Message, tools
|
||||
|
||||
// 发送请求
|
||||
buf, _ := json.Marshal(body)
|
||||
url := c.baseURL + "/chat/completions"
|
||||
url := ResolveChatCompletionsURL(c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf))
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -258,7 +258,7 @@ func (c *OpenAIClient) StreamChat(ctx context.Context, messages []types.Message,
|
||||
}
|
||||
|
||||
buf, _ := json.Marshal(body)
|
||||
url := c.baseURL + "/chat/completions"
|
||||
url := ResolveChatCompletionsURL(c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf))
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
29
internal/llm/openai_compat_url.go
Normal file
29
internal/llm/openai_compat_url.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package llm
|
||||
|
||||
import "strings"
|
||||
|
||||
// ResolveChatCompletionsURL 将配置里的 baseURL 归一成最终 Chat Completions 请求地址。
|
||||
//
|
||||
// 背景:xk_ai_platform.api_url 与 PHP 约定存「完整路径」
|
||||
// (如 https://api.deepseek.com/v1/chat/completions),而 yaml 常只写 host
|
||||
// (如 https://api.deepseek.com)。DeepSeek/OpenAI/Qwen 客户端若无脑追加
|
||||
// /chat/completions,会把完整路径拼成双重路径导致厂商 404。
|
||||
//
|
||||
// 规则:
|
||||
// - 已以 /chat/completions 结尾 → 原样使用(兼容 DB 完整路径)
|
||||
// - 以 /v1 结尾 → 追加 /chat/completions
|
||||
// - 其它 → 追加 /v1/chat/completions(兼容仅 host 的 yaml)
|
||||
func ResolveChatCompletionsURL(baseURL string) string {
|
||||
u := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if u == "" {
|
||||
return u
|
||||
}
|
||||
lower := strings.ToLower(u)
|
||||
if strings.HasSuffix(lower, "/chat/completions") {
|
||||
return u
|
||||
}
|
||||
if strings.HasSuffix(lower, "/v1") {
|
||||
return u + "/chat/completions"
|
||||
}
|
||||
return u + "/v1/chat/completions"
|
||||
}
|
||||
28
internal/llm/openai_compat_url_test.go
Normal file
28
internal/llm/openai_compat_url_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package llm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveChatCompletionsURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// DB 完整路径:不可再拼
|
||||
{"https://api.deepseek.com/v1/chat/completions", "https://api.deepseek.com/v1/chat/completions"},
|
||||
{"https://api.deepseek.com/v1/chat/completions/", "https://api.deepseek.com/v1/chat/completions"},
|
||||
// yaml 仅 host
|
||||
{"https://api.deepseek.com", "https://api.deepseek.com/v1/chat/completions"},
|
||||
{"https://api.deepseek.com/", "https://api.deepseek.com/v1/chat/completions"},
|
||||
// 已到 /v1
|
||||
{"https://api.deepseek.com/v1", "https://api.deepseek.com/v1/chat/completions"},
|
||||
// OpenAI 常见写法
|
||||
{"https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"},
|
||||
{"https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ResolveChatCompletionsURL(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("ResolveChatCompletionsURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,13 +77,17 @@ func createAzureClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *AzureOpenAIClient) Name() string { return c.deployment }
|
||||
func (c *AzureOpenAIClient) Provider() string { return "azure" }
|
||||
func (c *AzureOpenAIClient) Name() string { return c.deployment }
|
||||
func (c *AzureOpenAIClient) Provider() string { return "azure" }
|
||||
func (c *AzureOpenAIClient) Supports(cap string) bool { return c.capabilities[cap] }
|
||||
|
||||
func (c *AzureOpenAIClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) {
|
||||
// Azure 的 chat completions URL 格式
|
||||
url := fmt.Sprintf("%s/chat/completions?api-version=%s", c.baseURL, c.apiVersion)
|
||||
// Azure 部署基址常不含 /chat/completions;若配置已是完整路径则不再追加,避免双重路径
|
||||
base := strings.TrimRight(strings.TrimSpace(c.baseURL), "/")
|
||||
if !strings.HasSuffix(strings.ToLower(base), "/chat/completions") {
|
||||
base = base + "/chat/completions"
|
||||
}
|
||||
url := fmt.Sprintf("%s?api-version=%s", base, c.apiVersion)
|
||||
|
||||
openAIMsgs := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
@@ -248,7 +252,7 @@ func createOllamaClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
model: model,
|
||||
client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second},
|
||||
capabilities: map[string]bool{
|
||||
CapFunctionCalling: true, // Ollama 支持 tool calling(部分模型)
|
||||
CapFunctionCalling: true, // Ollama 支持 tool calling(部分模型)
|
||||
CapStreaming: true,
|
||||
CapJSONMode: false, // 原生不支持,需 Prompt 引导
|
||||
CapLongContext: false, // 取决于具体模型
|
||||
@@ -260,8 +264,8 @@ func createOllamaClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *OllamaClient) Name() string { return c.model }
|
||||
func (c *OllamaClient) Provider() string { return "ollama" }
|
||||
func (c *OllamaClient) Name() string { return c.model }
|
||||
func (c *OllamaClient) Provider() string { return "ollama" }
|
||||
func (c *OllamaClient) Supports(cap string) bool { return c.capabilities[cap] }
|
||||
|
||||
func (c *OllamaClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) {
|
||||
@@ -477,8 +481,8 @@ func createQwenClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *QwenClient) Name() string { return c.model }
|
||||
func (c *QwenClient) Provider() string { return "qwen" }
|
||||
func (c *QwenClient) Name() string { return c.model }
|
||||
func (c *QwenClient) Provider() string { return "qwen" }
|
||||
func (c *QwenClient) Supports(cap string) bool { return c.capabilities[cap] }
|
||||
|
||||
func (c *QwenClient) Chat(ctx context.Context, messages []types.Message, tools []types.Tool) (*types.Message, error) {
|
||||
@@ -519,7 +523,7 @@ func (c *QwenClient) Chat(ctx context.Context, messages []types.Message, tools [
|
||||
}
|
||||
|
||||
buf, _ := json.Marshal(body)
|
||||
url := c.baseURL + "/chat/completions"
|
||||
url := ResolveChatCompletionsURL(c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf))
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -627,8 +631,8 @@ func createMockClient(cfg *config.LLMConfigEx) (LLMClient, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *MockClient) Name() string { return c.name }
|
||||
func (c *MockClient) Provider() string { return c.provider }
|
||||
func (c *MockClient) Name() string { return c.name }
|
||||
func (c *MockClient) Provider() string { return c.provider }
|
||||
func (c *MockClient) Supports(cap string) bool {
|
||||
// Mock 支持所有能力(测试用)
|
||||
return true
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tcm-agent/internal/agent"
|
||||
@@ -49,11 +50,11 @@ import (
|
||||
|
||||
// EnhancerService 知识增强服务
|
||||
type EnhancerService struct {
|
||||
maxkb *tool.MaxKBClient // MaxKB 客户端(kb_enabled=false 或 source=local 时可为 nil)
|
||||
localSearch *kb.Searcher // 本地知识库检索器(V1 默认走这条路径)
|
||||
llmRouter *llm.ModelRouter // 模型路由(按 scene 取 LLM)
|
||||
llmFallback *llm.FallbackChain
|
||||
cfg *config.Config
|
||||
maxkb *tool.MaxKBClient // MaxKB 客户端(kb_enabled=false 或 source=local 时可为 nil)
|
||||
localSearch *kb.Searcher // 本地知识库检索器(V1 默认走这条路径)
|
||||
llmRouter *llm.ModelRouter // 模型路由(按 scene 取 LLM)
|
||||
llmFallback *llm.FallbackChain
|
||||
cfg *config.Config
|
||||
toolRegistry map[string]types.Tool // ReactLoop 用:工具注册表(name → tool);非空时启用 Function Calling
|
||||
}
|
||||
|
||||
@@ -108,17 +109,20 @@ func (s *EnhancerService) WithTools(tools map[string]types.Tool) *EnhancerServic
|
||||
|
||||
// EnhanceRequest PHP 发来的增强请求
|
||||
type EnhanceRequest struct {
|
||||
Scene string `json:"scene"` // 场景:medical_record / prescription
|
||||
Context string `json:"context"` // 检索关键词(如"痰湿中阻 煎法")
|
||||
Messages []types.Message `json:"messages"` // PHP 拼好的完整消息列表
|
||||
KBEnabled bool `json:"kb_enabled"` // 是否启用知识库检索
|
||||
TopK int `json:"top_k,omitempty"` // 检索条数(默认 5)
|
||||
Provider string `json:"provider,omitempty"` // 强制用某 provider(空则走路由表)
|
||||
Scene string `json:"scene"` // 场景:medical_record / prescription
|
||||
Context string `json:"context"` // 检索关键词(如"痰湿中阻 煎法")
|
||||
Messages []types.Message `json:"messages"` // PHP 拼好的完整消息列表
|
||||
KBEnabled bool `json:"kb_enabled"` // 是否启用知识库检索
|
||||
TopK int `json:"top_k,omitempty"` // 检索条数(默认 5)
|
||||
Provider string `json:"provider,omitempty"` // 强制用某 provider(空则走路由表)
|
||||
AgentConfig *AgentConfigOpts `json:"agent_config,omitempty"` // 可选:PHP 透传覆盖 DB 中的 Agent 配置(不传则 Go 直读 DB)
|
||||
// 生成参数(P0 修复:此前 PHP 按场景调的参数在 via-agent 路径被丢弃)
|
||||
// 0 表示"未显式指定",走客户端默认(deepseek/spark 默认 0.3 / 4096)
|
||||
Temperature float64 `json:"temperature,omitempty"` // 生成温度(0-2)
|
||||
MaxTokens int `json:"max_tokens,omitempty"` // 单次响应 token 上限
|
||||
// Count 一次生成几份(默认 1,上限 5)。>1 时 Go 并行打多路 LLM,按槽位返回 Results;
|
||||
// 单路失败不拖死整批,至少成功 1 路才算业务成功。
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
// AgentConfigOpts 可选的 Agent 配置覆盖项(PHP 透传)
|
||||
@@ -145,17 +149,30 @@ type EnhanceStep struct {
|
||||
StartedAt int64 `json:"started_at"` // unix 秒
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
Detail string `json:"detail,omitempty"` // 备注(如命中文档数 / 失败原因)
|
||||
Status int `json:"status"` // 0进行中 1成功 2失败(与 xk_ai_generation.status 一致)
|
||||
lastMessage *types.Message `json:"-"` // ReactLoop 内部用:缓存 LLM 返回的 Message(不入 JSON)
|
||||
Status int `json:"status"` // 0进行中 1成功 2失败(与 xk_ai_generation.status 一致)
|
||||
lastMessage *types.Message `json:"-"` // ReactLoop 内部用:缓存 LLM 返回的 Message(不入 JSON)
|
||||
}
|
||||
|
||||
// EnhanceSlotResult 多份生成时每个槽位的结果(成功填 Content,失败填 Error)
|
||||
type EnhanceSlotResult struct {
|
||||
Index int `json:"index"` // 0-based 槽位
|
||||
OK bool `json:"ok"` // 本槽是否成功
|
||||
Content string `json:"content,omitempty"` // 成功时的 LLM 文本
|
||||
Error string `json:"error,omitempty"` // 失败原因
|
||||
Steps []EnhanceStep `json:"steps,omitempty"` // 本槽独立步骤
|
||||
Provider string `json:"provider,omitempty"` // 本槽实际 provider
|
||||
Model string `json:"model,omitempty"` // 本槽实际 model
|
||||
}
|
||||
|
||||
// EnhanceResponse 返回给 PHP 的结果
|
||||
type EnhanceResponse struct {
|
||||
Content string `json:"content"` // LLM 生成的文本
|
||||
Provider string `json:"provider"` // 实际使用的供应商
|
||||
Model string `json:"model"` // 实际使用的模型名
|
||||
Steps []EnhanceStep `json:"steps"` // 每一步过程(PHP 据此写 step 子表)
|
||||
TotalMs int `json:"total_ms"` // 总耗时
|
||||
Content string `json:"content"` // 第一份成功 content(兼容旧 PHP)
|
||||
Contents []string `json:"contents,omitempty"` // 仅成功内容数组(可选兼容)
|
||||
Results []EnhanceSlotResult `json:"results,omitempty"` // 多份时按槽位完整结果
|
||||
Provider string `json:"provider"` // 实际使用的供应商(首份成功)
|
||||
Model string `json:"model"` // 实际使用的模型名(首份成功)
|
||||
Steps []EnhanceStep `json:"steps"` // 共享步骤 + 汇总(单份时完整)
|
||||
TotalMs int `json:"total_ms"` // 总耗时(并行时约等于最慢一路)
|
||||
// cfgSource 本次请求的配置来源(内部字段,不返回 PHP):
|
||||
// doEnhance 写入,Enhance 包装层读出来写 RunLog——
|
||||
// 用 resp 传递而不是 service 字段,保证并发请求间不串号
|
||||
@@ -267,6 +284,13 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (*
|
||||
}
|
||||
}
|
||||
|
||||
// 多份并行:KB/守卫/客户端解析只做一次,LLM 按槽位 goroutine 打;
|
||||
// 处方严格 JSON 场景 PHP 已关 React,多份路径统一走单次 Chat,避免 N×多轮成本爆炸
|
||||
count := normalizeEnhanceCount(req.Count)
|
||||
if count > 1 {
|
||||
return s.doEnhanceMulti(ctx, req, messages, client, provider, meta, agentCfg, totalStart, resp, count)
|
||||
}
|
||||
|
||||
if reactOn {
|
||||
// ---- 路径 A:ReactLoop(多轮 + Planning + Reflection + JSON 修复)----
|
||||
reactReq := &ReactLoopRequest{
|
||||
@@ -443,6 +467,247 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (*
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// normalizeEnhanceCount 钳制一次生成份数:默认 1,上限 5
|
||||
func normalizeEnhanceCount(n int) int {
|
||||
if n <= 1 {
|
||||
return 1
|
||||
}
|
||||
if n > 5 {
|
||||
return 5
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// doEnhanceMulti 多份并行 LLM:共享前置步骤,按槽位返回成功/失败
|
||||
//
|
||||
// 为什么并行:份数>1 时串行会把医生等待拉到 N× 单次;WaitGroup 并行后墙钟约等于最慢一路。
|
||||
// 部分失败语义:某路超时/LLM 错误只标该槽 ok=false,其它成功槽照常返回;全部失败才 error。
|
||||
func (s *EnhancerService) doEnhanceMulti(
|
||||
ctx context.Context,
|
||||
req *EnhanceRequest,
|
||||
messages []types.Message,
|
||||
client llm.LLMClient,
|
||||
provider string,
|
||||
meta resolveMeta,
|
||||
agentCfg *agentcfg.AllConfig,
|
||||
totalStart time.Time,
|
||||
resp *EnhanceResponse,
|
||||
count int,
|
||||
) (*EnhanceResponse, error) {
|
||||
log.Printf("[Enhancer] 多份并行开始 scene=%s count=%d provider=%s", req.Scene, count, provider)
|
||||
|
||||
slots := make([]EnhanceSlotResult, count)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slots[idx] = EnhanceSlotResult{
|
||||
Index: idx,
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("panic: %v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
slotMsgs := cloneMessagesWithVariantHint(messages, idx, count)
|
||||
content, steps, usedProvider, usedModel, err := s.runSingleChatSlot(
|
||||
ctx, req, slotMsgs, client, provider, meta, agentCfg, idx,
|
||||
)
|
||||
if err != nil {
|
||||
slots[idx] = EnhanceSlotResult{
|
||||
Index: idx,
|
||||
OK: false,
|
||||
Error: err.Error(),
|
||||
Steps: steps,
|
||||
Provider: usedProvider,
|
||||
Model: usedModel,
|
||||
}
|
||||
return
|
||||
}
|
||||
slots[idx] = EnhanceSlotResult{
|
||||
Index: idx,
|
||||
OK: true,
|
||||
Content: content,
|
||||
Steps: steps,
|
||||
Provider: usedProvider,
|
||||
Model: usedModel,
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
resp.Results = slots
|
||||
successContents := make([]string, 0, count)
|
||||
var firstOK *EnhanceSlotResult
|
||||
successN := 0
|
||||
for i := range slots {
|
||||
if slots[i].OK {
|
||||
successN++
|
||||
successContents = append(successContents, slots[i].Content)
|
||||
if firstOK == nil {
|
||||
cp := slots[i]
|
||||
firstOK = &cp
|
||||
}
|
||||
}
|
||||
// 汇总步骤时带上槽位前缀,便于 PHP 写子表排障
|
||||
for _, st := range slots[i].Steps {
|
||||
st.Detail = fmt.Sprintf("[slot%d] %s", i, st.Detail)
|
||||
resp.Steps = append(resp.Steps, st)
|
||||
}
|
||||
}
|
||||
resp.Contents = successContents
|
||||
resp.TotalMs = int(time.Since(totalStart).Milliseconds())
|
||||
|
||||
if firstOK == nil {
|
||||
log.Printf("[Enhancer] 多份并行全部失败 scene=%s count=%d total=%dms", req.Scene, count, resp.TotalMs)
|
||||
return resp, fmt.Errorf("多份生成全部失败(%d 路)", count)
|
||||
}
|
||||
|
||||
resp.Content = firstOK.Content
|
||||
resp.Provider = firstOK.Provider
|
||||
resp.Model = firstOK.Model
|
||||
log.Printf("[Enhancer] 多份并行完成 scene=%s success=%d/%d total=%dms",
|
||||
req.Scene, successN, count, resp.TotalMs)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// cloneMessagesWithVariantHint 复制 messages,并在末条 user 追加「第 i 套方案」提示以拉开差异
|
||||
func cloneMessagesWithVariantHint(src []types.Message, idx, total int) []types.Message {
|
||||
out := make([]types.Message, len(src))
|
||||
copy(out, src)
|
||||
if total <= 1 || len(out) == 0 {
|
||||
return out
|
||||
}
|
||||
hint := fmt.Sprintf(
|
||||
"\n\n【多方案要求】请生成第 %d/%d 套组方方案,与其它套方案在用药组合或君臣佐使上尽量有可对比的差异;仍只输出约定 JSON,不要解释。",
|
||||
idx+1, total,
|
||||
)
|
||||
// 从后往前找最后一条 user,找不到则追加
|
||||
for i := len(out) - 1; i >= 0; i-- {
|
||||
if strings.EqualFold(strings.TrimSpace(out[i].Role), "user") {
|
||||
out[i].Content = out[i].Content + hint
|
||||
return out
|
||||
}
|
||||
}
|
||||
out = append(out, types.Message{Role: "user", Content: strings.TrimSpace(hint)})
|
||||
return out
|
||||
}
|
||||
|
||||
// runSingleChatSlot 单槽一次 Chat(含 fallback),供多份并行调用
|
||||
//
|
||||
// 每个 goroutine 独立温度微调(基温 + idx*0.05,上限 0.6),进一步提高方案差异。
|
||||
func (s *EnhancerService) runSingleChatSlot(
|
||||
ctx context.Context,
|
||||
req *EnhanceRequest,
|
||||
messages []types.Message,
|
||||
client llm.LLMClient,
|
||||
provider string,
|
||||
meta resolveMeta,
|
||||
agentCfg *agentcfg.AllConfig,
|
||||
idx int,
|
||||
) (content string, steps []EnhanceStep, usedProvider, usedModel string, err error) {
|
||||
usedProvider = provider
|
||||
usedModel = client.Name()
|
||||
llmStep := EnhanceStep{
|
||||
StepType: "llm_call",
|
||||
StartedAt: time.Now().Unix(),
|
||||
}
|
||||
llmStart := time.Now()
|
||||
|
||||
opts := llm.ChatOpts{}
|
||||
if agentCfg != nil && agentCfg.TokenBudget.Enabled {
|
||||
opts.MaxTokens = agentCfg.TokenBudget.MaxTokensPerCall
|
||||
}
|
||||
baseTemp := req.Temperature
|
||||
if baseTemp <= 0 {
|
||||
baseTemp = 0.2
|
||||
}
|
||||
// 槽位温度微调:略抬高后续方案温度,拉开差异但仍偏稳
|
||||
opts.Temperature = baseTemp + float64(idx)*0.05
|
||||
if opts.Temperature > 0.6 {
|
||||
opts.Temperature = 0.6
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
if opts.MaxTokens == 0 || req.MaxTokens < opts.MaxTokens {
|
||||
opts.MaxTokens = req.MaxTokens
|
||||
}
|
||||
}
|
||||
|
||||
msg, chatResult := s.callLLMWithMeta(ctx, client, messages, nil, opts)
|
||||
llmStep.DurationMs = int(time.Since(llmStart).Milliseconds())
|
||||
llmStep.FinishedAt = time.Now().Unix()
|
||||
llmStep.Provider = usedProvider
|
||||
llmStep.Model = usedModel
|
||||
if meta.APIKeyID > 0 {
|
||||
llmStep.APIKeyID = meta.APIKeyID
|
||||
}
|
||||
if chatResult != nil {
|
||||
llmStep.PromptTokens = chatResult.PromptTokens
|
||||
llmStep.CompletionTokens = chatResult.CompletionTokens
|
||||
llmStep.TotalTokens = chatResult.TotalTokens
|
||||
llmStep.Usage = chatResult.Usage
|
||||
if llmStep.APIKeyID == 0 {
|
||||
llmStep.APIKeyID = chatResult.APIKeyID
|
||||
}
|
||||
}
|
||||
|
||||
if msg == nil && s.llmFallback != nil && provider != "" {
|
||||
sceneKey := req.Scene
|
||||
if sceneKey == "" {
|
||||
sceneKey = "emr-generator"
|
||||
}
|
||||
chain, ok := s.llmFallback.GetChain(sceneKey)
|
||||
if ok {
|
||||
for _, nextProvider := range chain {
|
||||
if nextProvider == provider {
|
||||
continue
|
||||
}
|
||||
fbClient, fbErr := s.llmRouter.GetByProvider(nextProvider)
|
||||
if fbErr != nil {
|
||||
continue
|
||||
}
|
||||
fbMsg, fbChatResult := s.callLLMWithMeta(ctx, fbClient, messages, nil, opts)
|
||||
if fbMsg != nil {
|
||||
client = fbClient
|
||||
usedProvider = nextProvider
|
||||
usedModel = client.Name()
|
||||
msg = fbMsg
|
||||
chatResult = fbChatResult
|
||||
llmStep.Provider = usedProvider
|
||||
llmStep.Model = usedModel
|
||||
if fbChatResult != nil {
|
||||
llmStep.PromptTokens = fbChatResult.PromptTokens
|
||||
llmStep.CompletionTokens = fbChatResult.CompletionTokens
|
||||
llmStep.TotalTokens = fbChatResult.TotalTokens
|
||||
llmStep.Usage = fbChatResult.Usage
|
||||
llmStep.APIKeyID = fbChatResult.APIKeyID
|
||||
llmStep.Detail = "成功(fallback)"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if msg == nil {
|
||||
llmStep.Status = 2
|
||||
llmStep.Detail = "LLM 调用失败"
|
||||
steps = append(steps, llmStep)
|
||||
return "", steps, usedProvider, usedModel, fmt.Errorf("LLM 调用失败")
|
||||
}
|
||||
|
||||
llmStep.Status = 1
|
||||
if chatResult != nil && chatResult.FinishReason != "" {
|
||||
llmStep.Detail = "成功 | finish_reason=" + chatResult.FinishReason
|
||||
} else if llmStep.Detail == "" {
|
||||
llmStep.Detail = "成功"
|
||||
}
|
||||
steps = append(steps, llmStep)
|
||||
return msg.Content, steps, usedProvider, usedModel, nil
|
||||
}
|
||||
|
||||
// jsonSnapshot 把 BudgetSnapshot 序列化成短字符串(仅用于日志)
|
||||
func jsonSnapshot(s agent.BudgetSnapshot) string {
|
||||
b, _ := json.Marshal(s)
|
||||
@@ -583,11 +848,11 @@ func (s *EnhancerService) getTimeoutForProvider(provider string) int {
|
||||
// 为什么要限:TopK*整段 chunk + 方剂可能过万字,直接塞 system 会稀释业务指令
|
||||
// 且逼近轻量模型上下文上限;预算按"药材知识为主、方剂为辅"分配
|
||||
const (
|
||||
kbDocMaxRunes = 500 // 单条药材知识注入上限
|
||||
kbDocsTotalRunes = 2600 // 药材知识总预算
|
||||
kbFormulaMaxRunes = 400 // 单条参考方剂注入上限
|
||||
kbFormulaTopK = 3 // 方剂检索条数(方剂是"参考骨架",2~3 个足够化裁)
|
||||
kbMinScoreRatio = 0.15 // 相对分数阈值:低于最高分 15% 的命中视为弱相关噪声
|
||||
kbDocMaxRunes = 500 // 单条药材知识注入上限
|
||||
kbDocsTotalRunes = 2600 // 药材知识总预算
|
||||
kbFormulaMaxRunes = 400 // 单条参考方剂注入上限
|
||||
kbFormulaTopK = 3 // 方剂检索条数(方剂是"参考骨架",2~3 个足够化裁)
|
||||
kbMinScoreRatio = 0.15 // 相对分数阈值:低于最高分 15% 的命中视为弱相关噪声
|
||||
)
|
||||
|
||||
// performKBRetrieval 执行知识库检索(按 agentcfg.KB.Source 分流)
|
||||
@@ -681,11 +946,11 @@ func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRe
|
||||
|
||||
// localKBHit 本地多库归并检索的中间结果(按 doc 去重、加权后排序用)
|
||||
type localKBHit struct {
|
||||
docID uint
|
||||
title string
|
||||
content string
|
||||
score float64
|
||||
libName string
|
||||
docID uint
|
||||
title string
|
||||
content string
|
||||
score float64
|
||||
libName string
|
||||
}
|
||||
|
||||
// retrieveFromLocalMulti 本地知识库多库归并检索(P1 主路径)
|
||||
@@ -895,6 +1160,7 @@ func listActiveLibraries() ([]libraryView, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
//
|
||||
// 插入位置策略:
|
||||
// - 若 messages[0] 是 system → 在其后插入(保持业务 system 优先级最高)
|
||||
|
||||
Reference in New Issue
Block a user