# BE04 — AI PathCoach 模块设计 > **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + pdftotext + OpenAI 兼容接口** > **参考:`backend-go/internal/api/ai_chat.go`** --- ## 1. 模块职责 - 全局 AI 聊天框的后端支持 - 上下文注入(当前产品/课程信息自动带入) - 知识检索(Go 内 brute-force 余弦向量检索 + 关键词兜底 → Prompt 注入) - SSE 流式响应 + 非流式调用(快捷动作) - 3 个快捷动作(情景演练/查佣金/产品对比) --- ## 2. 架构 ``` 用户消息 + 页面上下文 │ ▼ ┌──────────────────┐ │ 知识检索 │ │ Go 内 brute-force │ │ 余弦 + 关键词 │ │ → 匹配段落 │ └──────┬───────────┘ │ 上下文片段 ▼ ┌──────────────────┐ │ Prompt 组装 │ │ System Prompt │ │ + 知识上下文 │ │ + 对话历史 │ └──────┬───────────┘ │ ▼ ┌──────────────────────────────┐ │ buildLLMAdapter() 工厂 │ │ → 读取配置(DB → .env) │ │ → Fail Fast 缺配置抛 501 │ │ → 返回 OpenAICompatible │ └──────┬───────────────────────┘ │ ▼ ┌──────────────────┐ │ net/http 调用 │ │ /chat/completions│ │ SSE 流式 / 非流式│ └──────────────────┘ ``` --- ## 3. 配置优先级与 Fail Fast ```go // backend-go/internal/ai/llm.go type LLMConfig struct { BaseURL string APIKey string Model string } func resolveLLMConfig(db *gorm.DB) (LLMConfig, error) { // 从 system_config 表读取(优先级最高) // 回退到 .env 文件 // 缺任何一项 → 返回错误,由 Gin handler 抛 501 baseURL := getSystemConfig(db, "llm_base_url") apiKey := getSystemConfig(db, "llm_api_key") model := getSystemConfig(db, "llm_model") if baseURL == "" || apiKey == "" || model == "" { return LLMConfig{}, fmt.Errorf("LLM 服务未配置——缺失配置项") } return LLMConfig{BaseURL: baseURL, APIKey: apiKey, Model: model}, nil } ``` --- ## 4. LLM 适配器(Go net/http 实现) 无 openai SDK 依赖,纯 Go 标准库实现,支持 SSE 流式 + token usage 采集。 ```go // backend-go/internal/ai/llm.go type LLMAdapter struct { baseURL string apiKey string model string client *http.Client lastUsage map[string]int } func (a *LLMAdapter) generate(messages []map[string]interface{}, stream bool) (string, error) { payload := map[string]interface{}{ "model": a.model, "messages": messages, "stream": stream, "temperature": 0.7, "max_tokens": 2048, } if stream { payload["stream_options"] = map[string]interface{}{"include_usage": true} } resp, err := a.client.Post( a.baseURL+"/chat/completions", "application/json", json.NewEncoder(io.NopCloser(bytes.NewBuffer(payload))), ) if err != nil { return "", fmt.Errorf("LLM 调用失败: %w", err) } defer resp.Body.Close() if stream { // SSE 流式处理 reader := bufio.NewReader(resp.Body) for { line, err := reader.ReadString('\n') if err != nil { break } if !strings.HasPrefix(line, "data: ") { continue } // parse SSE chunk... } } // 非流式:直接解析 JSON var result struct { Choices []struct { Message struct { Content string } } Usage struct { PromptTokens int CompletionTokens int } } json.NewDecoder(resp.Body).Decode(&result) return result.Choices[0].Message.Content, nil } ``` --- ## 5. 知识检索(Go 内 brute-force 余弦 + 关键词) ```go // backend-go/internal/ai/retrieve.go func RetrieveKnowledge(db *gorm.DB, query string, topK int) ([]string, error) { // 1. 向量检索:embedding 走 Ollama bge-m3,Go 内 brute-force 余弦相似度 queryVec := embedSingle(query) // []float32 // 2. 关键词兜底:LIKE 前缀匹配 var chunks []model.KnowledgeChunk db.Model(&model.KnowledgeChunk{}). Where("content LIKE ? LIMIT ?", "%"+query+"%", topK). Find(&chunks) // 合并去重,取 topK return mergeAndTopK(queryVec, chunks, topK) } ``` --- ## 6. System Prompt ```go const SYSTEM_PROMPT = `你是一个博昇内部培训平台的 AI 助教 PathCoach。 你的职责: 1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关的问题 2. 严格依赖已审批知识库的内容回答 3. 如果知识库中未找到相关资料,明确回答「未找到相关资料」,不得臆测 禁止行为: 1. 禁止闲聊 2. 禁止编造数据 3. 禁止回答超出业务范围的问题 4. 禁止泄露敏感信息 当前页面上下文: {page_context} 知识库相关片段: {knowledge_context} ` ``` --- ## 7. API 实现 ### 路由注册(`backend-go/internal/api/router.go`) ```go // ai 组 router.POST("/api/ai-chat/message", middleware.JWTAuth(), chat.ChatMessage) router.GET("/api/ai-chat/quick-actions", middleware.JWTAuth(), chat.GetQuickActions) router.POST("/api/ai-chat/quick-action", middleware.JWTAuth(), chat.TriggerQuickAction) ``` ### ChatMessage(SSE 流式) ```go // backend-go/internal/api/ai_chat.go func ChatMessage(c *gin.Context) { uid := middleware.GetUserID(c) var req struct { Message string `json:"message"` Context map[string]any `json:"context"` History []map[string]string `json:"history"` } c.ShouldBindJSON(&req) // 1. 检索知识 knowledge, _ := ai.RetrieveKnowledge(store.DB, req.Message, 5) // 2. 组装 Prompt system := fmt.Sprintf(ai.SYSTEM_PROMPT, "page_context": jsonEncode(req.Context), "knowledge_context": strings.Join(knowledge, "\n\n"), ) messages := []map[string]string{ {"role": "system", "content": system}, } messages = append(messages, req.History...) messages = append(messages, map[string]string{ "role": "user", "content": req.Message, }) // 3. 构建 LLM 适配器(缺配置即抛 501) llm, err := ai.BuildLLMAdapter(store.DB) if err != nil { c.JSON(501, gin.M{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."}) return } // 4. SSE 流式响应 c.Stream(func(w io.Writer) bool { // write SSE chunks... return true }) } ``` ### 快捷动作 ```go func TriggerQuickAction(c *gin.Context) { uid := middleware.GetUserID(c) var req struct { ActionID string `json:"action_id" binding:"required"` Params string `json:"params"` } c.ShouldBindJSON(&req) llm, _ := ai.BuildLLMAdapter(store.DB) var prompt string switch req.ActionID { case "commission": prompt = fmt.Sprintf("查询产品佣金信息,产品参数:%s", req.Params) case "compare": prompt = fmt.Sprintf("对比以下产品:%s", req.Params) case "scenario": prompt = fmt.Sprintf("开始销售情景演练,场景参数:%s", req.Params) } resp, _ := llm.Generate(messages) c.JSON(200, gin.M{ "code": 0, "data": gin.M{"result": resp}, }) } ``` --- ## 8. 上下文注入规则 | 页面 | 自动注入上下文 | 说明 | |------|--------------|------| | 产品详情 | `product_id`, `product_name`, `product_code` | 自动带入当前产品 | | 课程详情 | `course_id`, `course_name`, `related_product` | 自动带入当前课程及关联产品 | | 公司介绍 | `page: "company_intro"` | 提示 AI 当前页为公司介绍 | --- ## 9. 配置项 | 配置键 | 来源 | 说明 | |--------|------|------| | `llm_base_url` | system_config 表 / .env | LLM 服务地址,如 `http://192.168.1.100:11434/v1` | | `llm_api_key` | system_config 表 / .env | API Key,本地 Ollama 可填 `ollama` | | `llm_model` | system_config 表 / .env | 模型名,如 `qwen2.5:7b` | --- ## 10. 错误处理 | 场景 | HTTP 状态 | 响应 | |------|----------|------| | LLM 未配置(缺 base_url/key/model) | 501 | `{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."}` | | LLM 调用超时/网络错误 | 502 | `{"error": "llm_request_failed", "message": "LLM 服务不可达,请检查网络连接"}` | | LLM 返回空正文 | 502 | `{"error": "llm_empty_response", "message": "LLM 返回空结果"}` | | LLM 响应格式异常 | 502 | `{"error": "llm_response_error", "message": "LLM 响应异常"}` |