修复deepseek无法对话的bug

This commit is contained in:
李琦
2026-10-15 10:50:59 +08:00
parent 59a35db285
commit 8e880cdec7

View File

@@ -72,10 +72,11 @@ type resolveMeta struct {
// NewEnhancerService 构造函数
//
// 参数:
// maxkb - MaxKB 客户端(kb 关闭或 source=local 时可传 nil)
// router - 模型路由器(必填)
// fallback - 降级链(可空)
// cfg - 全局配置
//
// maxkb - MaxKB 客户端(kb 关闭或 source=local 时可传 nil)
// router - 模型路由器(必填)
// fallback - 降级链(可空)
// cfg - 全局配置
func NewEnhancerService(maxkb *tool.MaxKBClient, router *llm.ModelRouter, fallback *llm.FallbackChain, cfg *config.Config) *EnhancerService {
return &EnhancerService{
maxkb: maxkb,
@@ -150,9 +151,20 @@ type EnhanceStep struct {
FinishedAt int64 `json:"finished_at"`
Detail string `json:"detail,omitempty"` // 备注(如命中文档数 / 失败原因)
Status int `json:"status"` // 0进行中 1成功 2失败(与 xk_ai_generation.status 一致)
Hits []KbHit `json:"hits,omitempty"` // kb_retrieval 命中条目(PHP 写入 usage_json.hits,详情页展示)
lastMessage *types.Message `json:"-"` // ReactLoop 内部用:缓存 LLM 返回的 Message(不入 JSON)
}
// KbHit 知识库/金方检索命中(给生成记录详情展示出处与原文)
type KbHit struct {
Kind string `json:"kind"` // doc=知识库文档 formula=金方
Title string `json:"title"` // 文档标题 / 方名
Library string `json:"library,omitempty"` // 来源库名
Score float64 `json:"score,omitempty"` // 检索分(可选)
Content string `json:"content"` // 注入 prompt 的正文摘要
DocID uint `json:"doc_id,omitempty"` // 本地文档 id(排障)
}
// EnhanceSlotResult 多份生成时每个槽位的结果(成功填 Content,失败填 Error)
type EnhanceSlotResult struct {
Index int `json:"index"` // 0-based 槽位
@@ -242,6 +254,8 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (*
// ===== 步骤 1:知识库检索(可选) =====
// 根据 agentcfg.KB.Source 分流到 MaxKB 或本地知识库;
// 处方场景额外检索金方库(参考方剂),与药材知识分块注入
// 跳过时也写 kb_retrieval 步骤:PHP 落 xk_ai_generation_step 后,
// 后台生成记录时间轴才能看见「没检索」而不是误以为步骤丢失
if req.KBEnabled && strings.TrimSpace(req.Context) != "" {
step, kbDocs, kbFormulas, kerr := s.performKBRetrieval(ctx, req)
if step != nil {
@@ -251,6 +265,12 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (*
messages = injectKBContext(messages, kbDocs, kbFormulas)
}
}
} else {
skipReason := "未检索:场景开关关闭"
if req.KBEnabled {
skipReason = "未检索:检索词为空"
}
resp.Steps = append(resp.Steps, skippedKBRetrievalStep(skipReason))
}
// ===== 步骤 2:解析 LLM 客户端 =====
@@ -855,6 +875,23 @@ const (
kbMinScoreRatio = 0.15 // 相对分数阈值:低于最高分 15% 的命中视为弱相关噪声
)
// skippedKBRetrievalStep 构造「未真正检索」的 kb_retrieval 步骤
//
// 为什么必须写:PHP 只把 Go 返回的 steps 落入 xk_ai_generation_step,
// 静默跳过时后台生成记录时间轴上看不到「检索知识库」,会被当成步骤丢失。
// status=1:跳过是预期行为,不是失败;detail 写清原因供运营排查。
func skippedKBRetrievalStep(detail string) EnhanceStep {
now := time.Now().Unix()
return EnhanceStep{
StepType: "kb_retrieval",
StartedAt: now,
FinishedAt: now,
DurationMs: 0,
Status: 1,
Detail: detail,
}
}
// performKBRetrieval 执行知识库检索(按 agentcfg.KB.Source 分流)
//
// 返回值:
@@ -864,10 +901,10 @@ const (
// - err:检索失败原因(仅用于调用方决定是否阻断;step 内已经记录)
//
// 分流策略:
// 1. agentcfg.KB.Source == "local" → 走 LocalKB(V1 默认,多库归并)
// 2. agentcfg.KB.Source == "maxkb" → 走 MaxKB(需 maxkb 客户端可用)
// 3. 选定路径失败时不阻断主流程,调用方仍用原始 messages 调 LLM
// 4. 金方检索独立于 KB source(直查 z_xk 库),失败只降级不影响文档检索
// 1. agentcfg.KB.Source == "local" → 走 LocalKB(V1 默认,多库归并)
// 2. agentcfg.KB.Source == "maxkb" → 走 MaxKB(需 maxkb 客户端可用)
// 3. 选定路径失败时不阻断主流程,调用方仍用原始 messages 调 LLM
// 4. 金方检索独立于 KB source(直查 z_xk 库),失败只降级不影响文档检索
func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRequest) (*EnhanceStep, []string, []string, error) {
kbCfg := agentcfg.GetKBConfig()
topK := req.TopK
@@ -893,28 +930,33 @@ func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRe
}
var docs []string
var docHits []KbHit
var docsDetail string
var err error
source := kbCfg.Source
switch source {
case "local":
docs, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode)
docHits, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode)
docs = formatKbDocBlocks(docHits)
case "maxkb":
docs, err = s.retrieveFromMaxKB(ctx, query, topK)
docsDetail = fmt.Sprintf("命中 %d 条", len(docs))
docHits = kbHitsFromPlainDocs(docs, "maxkb")
default:
// 未知 source 回落到本地
source = "local(fallback)"
docs, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode)
docHits, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode)
docs = formatKbDocBlocks(docHits)
}
// 金方库检索(P1):处方场景第二检索源,动态读取平台维护的方剂
// 与药材知识独立:金方失败不影响文档结果(只补一条 detail 说明)
var formulas []string
var formulaHits []KbHit
if req.Scene == "prescription" {
var ferr error
formulas, ferr = retrieveGoldenFormulas(query, kbFormulaTopK)
formulas, formulaHits, ferr = retrieveGoldenFormulas(query, kbFormulaTopK)
if ferr != nil {
log.Printf("[Enhancer] 金方检索失败(不阻断): %v", ferr)
detailParts = append(detailParts, "方剂检索失败:"+ferr.Error())
@@ -941,6 +983,9 @@ func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRe
detail += "|" + strings.Join(detailParts, "|")
}
step.Detail = detail
if len(docHits)+len(formulaHits) > 0 {
step.Hits = append(docHits, formulaHits...)
}
return step, docs, formulas, nil
}
@@ -956,14 +1001,14 @@ type localKBHit struct {
// retrieveFromLocalMulti 本地知识库多库归并检索(P1 主路径)
//
// 与旧版(只查第一个库)的差异:
// 1. 遍历全部 status=1 的库分别检索,按分数归并
// 2. title 命中加权:查询词包含文档标题(如药名/方名直接出现在证候上下文里)
// 的命中 ×1.3——标题级命中的相关性远高于正文碰撞
// 3. 相对分数阈值:低于最高分 15% 的命中丢弃(过滤 ngram 2-gram 碰撞噪声)
// 4. 按 doc 去重:同一文档多个分段命中时只保留最高分那段,避免同一味药占满 TopK
// 1. 遍历全部 status=1 的库分别检索,按分数归并
// 2. title 命中加权:查询词包含文档标题(如药名/方名直接出现在证候上下文里)
// 的命中 ×1.3——标题级命中的相关性远高于正文碰撞
// 3. 相对分数阈值:低于最高分 15% 的命中丢弃(过滤 ngram 2-gram 碰撞噪声)
// 4. 按 doc 去重:同一文档多个分段命中时只保留最高分那段,避免同一味药占满 TopK
//
// 返回:格式化好的知识块列表 + 命中摘要(step detail 用)
func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query string, topK int, mode string) ([]string, string, error) {
// 返回:结构化命中(详情页展示)+ 命中摘要(step detail 用)
func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query string, topK int, mode string) ([]KbHit, string, error) {
if s.localSearch == nil {
return nil, "", fmt.Errorf("本地知识库检索器未初始化")
}
@@ -1009,7 +1054,7 @@ func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query stri
}
}
if len(all) == 0 {
return []string{}, "命中 0 条", nil
return []KbHit{}, "命中 0 条", nil
}
// 按 doc 去重:保留每个文档的最高分分段
bestByDoc := make(map[uint]localKBHit, len(all))
@@ -1034,24 +1079,27 @@ func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query stri
if len(filtered) > topK {
filtered = filtered[:topK]
}
// 格式化 + 总长度预算控制
out := make([]string, 0, len(filtered))
// 总长度预算:与注入 prompt 的截断一致,详情展示同一份正文
out := make([]KbHit, 0, len(filtered))
usedRunes := 0
for _, h := range filtered {
content := truncateRunesStr(h.content, kbDocMaxRunes)
var block string
if h.title == "" {
block = content
} else {
// 带来源标题与库名:LLM 引用时可指明出处,排障时可定位语料
block = fmt.Sprintf("【%s|来源:%s】\n%s", h.title, h.libName, content)
blockRunes := len([]rune(content))
if h.title != "" {
blockRunes += len([]rune(h.title)) + len([]rune(h.libName)) + 12
}
blockRunes := len([]rune(block))
if usedRunes+blockRunes > kbDocsTotalRunes && len(out) > 0 {
break // 预算用尽:保留已有条目(至少注入 1 条)
break
}
usedRunes += blockRunes
out = append(out, block)
out = append(out, KbHit{
Kind: "doc",
Title: h.title,
Library: h.libName,
Score: h.score,
Content: content,
DocID: h.docID,
})
}
desc := fmt.Sprintf("命中 %d 条(%s)", len(out), strings.Join(libHitDesc, ", "))
return out, desc, nil
@@ -1062,15 +1110,16 @@ func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query stri
// 两段式与 KB 检索一致:BOOLEAN 精确 → 零命中回退 NATURAL LANGUAGE。
// 输出格式固定三行:方名 / 组成(解析 drugs_json)/ 主治(优先译文),
// 提示词侧告知 LLM"可在参考方基础上化裁",不强制照搬
func retrieveGoldenFormulas(query string, topK int) ([]string, error) {
func retrieveGoldenFormulas(query string, topK int) ([]string, []KbHit, error) {
hits, err := dao.GoldenFormulaSearch(query, topK, false)
if (err != nil || len(hits) == 0) && len([]rune(query)) >= 3 {
hits, err = dao.GoldenFormulaSearch(query, topK, true)
}
if err != nil {
return nil, err
return nil, nil, err
}
out := make([]string, 0, len(hits))
kbHits := make([]KbHit, 0, len(hits))
for _, h := range hits {
drugs := formatFormulaDrugs(h.DrugsJSON)
if drugs == "" {
@@ -1082,9 +1131,18 @@ func retrieveGoldenFormulas(query string, topK int) ([]string, error) {
indication = strings.TrimSpace(h.IndicationOriginal)
}
block := fmt.Sprintf("〔%s〕组成:%s。主治:%s", h.Name, drugs, indication)
out = append(out, truncateRunesStr(block, kbFormulaMaxRunes))
block = truncateRunesStr(block, kbFormulaMaxRunes)
out = append(out, block)
kbHits = append(kbHits, KbHit{
Kind: "formula",
Title: h.Name,
Library: "金方库",
Score: h.Score,
Content: block,
DocID: h.ID,
})
}
return out, nil
return out, kbHits, nil
}
// formatFormulaDrugs 把金方 drugs_json 解析成"桂枝46.9g、芍药46.9g"形式
@@ -1144,6 +1202,37 @@ func (s *EnhancerService) retrieveFromMaxKB(ctx context.Context, query string, t
return s.maxkb.Search(ctx, query, topK)
}
// formatKbDocBlocks 把结构化命中格式化成注入 prompt 的知识块
func formatKbDocBlocks(hits []KbHit) []string {
out := make([]string, 0, len(hits))
for _, h := range hits {
if h.Title == "" {
out = append(out, h.Content)
continue
}
out = append(out, fmt.Sprintf("【%s|来源:%s】\n%s", h.Title, h.Library, h.Content))
}
return out
}
// kbHitsFromPlainDocs MaxKB 等只返回纯文本时,包一层结构化命中供详情展示
func kbHitsFromPlainDocs(docs []string, library string) []KbHit {
out := make([]KbHit, 0, len(docs))
for i, d := range docs {
text := strings.TrimSpace(d)
if text == "" {
continue
}
out = append(out, KbHit{
Kind: "doc",
Title: fmt.Sprintf("参考 %d", i+1),
Library: library,
Content: truncateRunesStr(text, kbDocMaxRunes),
})
}
return out
}
// listActiveLibraries 取所有启用的本地知识库(按 id 升序)
//
// 单独抽出来避免在 retrieveFromLocal 里直接 import dao(service 包不直接依赖 dao)
@@ -1161,6 +1250,7 @@ func listActiveLibraries() ([]libraryView, error) {
return out, nil
}
// injectKBContext 把检索到的知识块作为独立 system 消息注入
//
// 插入位置策略:
// - 若 messages[0] 是 system → 在其后插入(保持业务 system 优先级最高)