# LLM 集成 ## 概述 PR-Helper 集成了 OpenAI 兼容的 LLM API,用于自动生成 PR 描述和执行 AI 代码审查。支持流式响应,提供实时反馈。 ## 架构 ``` 前端 → SSE 客户端 → Handler → LLM 服务 → OpenAI API ↑ ↓ └──────── 流式响应 ←────────────┘ ``` ## 配置 ### 全局默认配置 ```go var DefaultSettings = map[string]string{ "llm.endpoint": "https://api.deepseek.com", "llm.api_key": "", "llm.model": "deepseek-v4-pro", } ``` ### 用户配置 每个用户可以独立配置 LLM 参数,存储在 `user_settings` 表中。 | 键名 | 说明 | 示例 | |------|------|------| | llm.endpoint | API 端点 | https://api.deepseek.com | | llm.api_key | API 密钥 | sk-... | | llm.model | 模型名称 | deepseek-v4-pro | ### 支持的 LLM 提供商 | 提供商 | 端点 | 模型 | |--------|------|------| | DeepSeek | https://api.deepseek.com | deepseek-v4-pro | | OpenAI | https://api.openai.com | gpt-4, gpt-3.5-turbo | | Azure OpenAI | 自定义 | 自定义 | | 本地部署 | http://localhost:8000 | 自定义 | ## LLM 服务 (services/llm.go) ### 配置读取 ```go func GetLLMConfig(db *sql.DB, userID int64) (LLMConfig, error) { config := LLMConfig{} rows, err := db.Query("SELECT `key`, value FROM user_settings WHERE user_id = ? AND `key` IN ('llm.endpoint', 'llm.api_key', 'llm.model')", userID) // ... if config.APIKey == "" { return config, fmt.Errorf("LLM API key not configured — please set it in the settings page") } if config.Model == "" { config.Model = "deepseek-v4-pro" } return config, nil } ``` ### 流式调用 ```go func ChatStream(config LLMConfig, messages []goopenai.ChatCompletionMessage, callback StreamCallback) (string, error) { clientConfig := goopenai.DefaultConfig(config.APIKey) if config.Endpoint != "" { clientConfig.BaseURL = config.Endpoint } client := goopenai.NewClientWithConfig(clientConfig) ctx := context.Background() stream, err := client.CreateChatCompletionStream(ctx, goopenai.ChatCompletionRequest{ Model: config.Model, Messages: messages, Stream: true, }) // ... var fullResponse strings.Builder for { response, err := stream.Recv() if err == io.EOF { break } // ... if len(response.Choices) > 0 { content := response.Choices[0].Delta.Content if content != "" { fullResponse.WriteString(content) if callback != nil { callback("content", map[string]interface{}{"content": content}) } } } } return fullResponse.String(), nil } ``` ### JSON 提取 ```go func extractJSON(s string) string { // 尝试从 Markdown 代码块提取 if idx := strings.Index(s, "```json"); idx >= 0 { start := idx + 7 if end := strings.Index(s[start:], "```"); end >= 0 { return strings.TrimSpace(s[start : start+end]) } } // 查找第一个 { 或 [ startObj := strings.Index(s, "{") startArr := strings.Index(s, "[") // 找到匹配的闭合括号 // ... return s[start:] } ``` ## PR 描述生成 ### 提示模板 ```markdown 你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份 PR 描述。 ## Commit 记录 - abc1234 feat: add new feature - def5678 fix: bug fix ## 代码变更 (Diff) diff --git a/main.go b/main.go ... 请直接输出 Markdown 格式的 PR 描述,包含以下部分: # 标题 **类型**: feat|fix|refactor|docs|chore|style|test|perf ## 概述 一段话概述变更内容 ## 详细说明 按模块分组的详细变更说明 ## 影响范围 影响范围说明 格式要求: - 引用文件名、函数名、变量名等代码标识时,必须用反引号包裹 - 直接写出实际的代码名称,不要用任何占位符替代 - 不要输出 JSON,直接输出 Markdown ``` ### 生成流程 1. **读取 LLM 配置**: 从用户设置获取 2. **打开仓库**: 使用 go-git 3. **获取提交**: 过滤 base 和 head 之间的提交 4. **获取差异**: 生成统一差异 5. **构建提示**: 包含提交记录和差异 6. **调用 LLM**: 流式生成 7. **返回结果**: Markdown 格式的 PR 描述 ### 差异截断 ```go // 截断 diff 如果太大(约 60k 字符以保持在 token 限制内) if len(diff) > 60000 { diff = diff[:60000] + "\n\n... [diff truncated due to size]" } ``` ## AI 代码审查 ### 单文件审查提示 ```markdown 你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。 ## 文件: main.go ## 变更行数: +42 / -10 ## Diff diff --git a/main.go b/main.go ... 请按以下 JSON 格式输出审查意见(直接输出 JSON 数组,不要包含 markdown 代码块标记): [ { "severity": "critical 或 warning 或 info", "description": "问题描述", "suggestion": "建议的修改方案", "code_example": "建议的代码(如有)" } ] 严重程度说明: - critical: 严重问题(安全漏洞、数据丢失风险、崩溃风险) - warning: 建议改进(性能问题、代码规范、可维护性) - info: 提示信息(最佳实践、可选优化) 如果代码没有问题,输出空数组 []。 请用中文回复。 ``` ### 汇总生成提示 ```markdown 以下是多个文件的代码审查结果,请给出整体评估: ### main.go (42 行变更) [warning] 变量未使用 [info] 可以使用更简洁的写法 ### utils.go (10 行变更) 没有发现问题 请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记): { "score": 7, "overall": "总体评价(2-3 句话)", "findings": "按严重程度排序的主要发现汇总", "recommendations": "改进建议,用换行分隔多条建议" } 注意:所有字段必须是字符串类型,不要使用数组。请用中文回复。 ``` ### Top-N 策略 ```go // 按变更行数排序 sort.Slice(files, func(i, j int) bool { return countDiffLines(files[i].Patch) > countDiffLines(files[j].Patch) }) // 应用 Top-N if topN > 0 && topN < len(files) { files = files[:topN] } ``` - 默认 Top-N: 20 - 可通过设置或请求参数调整 - 0 表示分析所有文件 ### 并发审查 ```go sem := make(chan struct{}, concurrency) var wg sync.WaitGroup for i, file := range files { wg.Add(1) go func(idx int, f FileDiff) { defer wg.Done() sem <- struct{}{} // 获取槽位 defer func() { <-sem }() // 释放槽位 // ... 审查逻辑 }(i, file) } ``` - 默认并发数: 5 - 信号量控制最大并发 - 线程安全的回调函数 ## 流式响应处理 ### 后端回调 ```go type StreamCallback func(event string, data interface{}) // 使用回调发送事件 callback("content", map[string]interface{}{"content": content}) callback("suggestion", map[string]interface{}{ "file": filename, "severity": severity, "content": content, }) ``` ### 前端处理 ```javascript SSE.post(url, body, { content: (data) => { // 追加 Markdown 内容 appendToOutput(data.content); }, suggestion: (data) => { // 显示审查建议 showSuggestion(data.file, data.severity, data.content); }, summary: (data) => { // 显示汇总 showSummary(data); } }); ``` ## 错误处理 ### API 密钥未配置 ```go if config.APIKey == "" { return config, fmt.Errorf("LLM API key not configured — please set it in the settings page") } ``` ### API 调用失败 ```go stream, err := client.CreateChatCompletionStream(ctx, request) if err != nil { return "", fmt.Errorf("create stream: %w", err) } ``` ### 流式读取错误 ```go response, err := stream.Recv() if err != nil { return fullResponse.String(), fmt.Errorf("stream recv: %w", err) } ``` ### JSON 解析失败 ```go jsonStr := extractJSON(fullResponse) var suggestions []ReviewSuggestion if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil { // 尝试单个对象 var single ReviewSuggestion if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil { suggestions = []ReviewSuggestion{single} } else { // 使用原始响应作为描述 suggestions = []ReviewSuggestion{{ Severity: "info", Description: fullResponse, }} } } ``` ## Token 管理 ### 估算 - 1 个中文字符 ≈ 2 tokens - 1 个英文单词 ≈ 1 token - 代码行 ≈ 2-3 tokens ### 限制 - 输入: 通常 4k-128k tokens - 输出: 通常 4k-8k tokens ### 优化 - 截断大型差异 - 只分析 Top-N 文件 - 压缩提示文本 ## 模型选择建议 ### DeepSeek - 优势: 中文支持好,代码理解强 - 适用: 中文项目,代码审查 - 模型: deepseek-v4-pro ### OpenAI GPT-4 - 优势: 综合能力强 - 适用: 复杂项目,多语言 - 模型: gpt-4, gpt-4-turbo ### 本地部署 - 优势: 数据隐私,无 API 费用 - 适用: 敏感代码,离线环境 - 模型: CodeLlama, Qwen ## 成本优化 ### 减少 Token 使用 - 截断大型差异 - 只分析关键文件 - 使用更小的模型 ### 缓存 - 缓存相同输入的输出 - 避免重复分析 ### 批量处理 - 合并多个小请求 - 减少 API 调用次数 ## 监控 ### 关键指标 - API 调用次数 - Token 使用量 - 响应时间 - 错误率 ### 日志 ```go log.Printf("LLM call: model=%s, tokens=%d, duration=%v", model, tokens, duration) ``` ## 安全 ### API 密钥 - 不要在代码中硬编码 - 使用环境变量或配置文件 - 定期轮换密钥 ### 数据隐私 - 敏感代码考虑本地部署 - 不要发送不必要的上下文 - 遵守数据合规要求 ## 故障排查 ### 常见问题 1. **API 密钥错误** - 检查密钥是否正确 - 确认密钥是否有效 2. **连接超时** - 检查网络连接 - 确认端点 URL 正确 3. **Token 超限** - 减少输入大小 - 使用更大的上下文窗口 4. **JSON 解析失败** - 检查提示格式 - 添加更明确的格式要求 ### 调试命令 ```bash # 测试 API 连接 curl https://api.deepseek.com/v1/models \ -H "Authorization: Bearer sk-..." # 测试流式响应 curl -N https://api.deepseek.com/v1/chat/completions \ -H "Authorization: Bearer sk-..." \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"Hello"}],"stream":true}' ```