Files
xk-ai-agent/main.go
2026-08-14 21:50:48 +08:00

248 lines
10 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 main
// ========================================================================
// 中医 AI Agent 系统 —— 程序入口
// ========================================================================
// 启动流程:
// config.Load → llm.InitLLM (工厂+路由+降级) → agent.InitRunner → router.Setup
//
// 模型层初始化链路:
// config.yaml 中定义模型池 → llm.NewProviderFactory → 注册各供应商
// → llm.NewModelRouter → 按场景名路由到具体模型
// → llm.NewFallbackChain → 降级保障
//
// 不同 Agent 场景自动使用不同模型,无需改代码。
// ========================================================================
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"tcm-agent/internal/agent"
"tcm-agent/internal/config"
"tcm-agent/internal/dao"
"tcm-agent/internal/llm"
"tcm-agent/internal/router"
"tcm-agent/internal/service"
)
// @title 中医 AI Agent API
// @version 2.0
// @description 基于 Go + MaxKB + 多模型工厂 的中医智能诊疗 Agent 系统
// @host localhost:8080
// @BasePath /api/v1
func main() {
// ========== ⓪ 挂接内存日志缓冲 ==========
// 日志同时写控制台和内存环形缓冲500 行),
// 供 /agent/view 面板「实时日志」Tab 增量拉取,控制台输出不受影响。
// 必须放在第一行——保证启动日志也能被面板看到
log.SetOutput(io.MultiWriter(os.Stdout, service.MemLog))
// ========== ① 加载配置 ==========
cfg := config.Load()
log.Printf("[启动] 配置加载完成 | 默认模型: %s | 模型池: %d 个",
cfg.LLM.DefaultProvider, len(cfg.LLM.Models))
// ========== ② 连接 z_xk 库,注入 AES 主密钥,准备从 DB 加载 LLM 配置 ==========
//
// 设计思路DB 优先 + 即时生效):
// - DB 是单一可信源(与 PHP 端 AiRuntimeConfigService 同源)
// - yaml/env 仅作为 DB 不可用时的兜底(如本地开发没连库)
// - 启动期不做"全量 merge"——改为运行时按需解析dao.LoadActiveLLMConfig
// 这样后台切完模型配置 60s 内全集群生效,无需重启 Go 进程
//
// 启动期仍然保留 mergeDBLLMConfigs 的意义:
// - 让 EnhanceService 走 DB 解析失败时(如 DB 抖动)能回落到一份"最新已知"配置,
// 而不是 yaml 默认的 deepseek
// - 同时把 ollama 等本地模型DB 中没有)的配置补全
if cfg.DB.DSN != "" {
if err := dao.Init(cfg.DB.DSN); err != nil {
// DB 连不上不致命:降级用 yaml/env 配置继续启动
log.Printf("[启动] ⚠️ z_xk 库连接失败,将使用 yaml/env 配置: %v", err)
} else {
// ★ 关键:注入 AES 主密钥,让 dao 能解密 api_key
// 不注入的话 LoadActiveLLMConfig 会直接报错,整个"DB 优先"链路失效
dao.SetEncryptKey(cfg.DB.EncryptKey)
log.Printf("[启动] ✅ z_xk 库连接成功,已注入 AES 主密钥DB 优先链路就绪")
// 仍然保留 mergeDBLLMConfigs 作为"启动期一次性 merge"——
// 它把所有平台配置塞进 cfg.LLM.Models作为 EnhanceService DB 解析失败时的兜底
if err := mergeDBLLMConfigs(cfg); err != nil {
log.Printf("[启动] ⚠️ 从 DB 加载 LLM 配置失败(不影响运行时解析): %v", err)
} else {
log.Printf("[启动] ↳ 启动期 merge 完成(运行时仍按需实时解析)")
}
}
} else {
log.Printf("[启动] ⚠️ 未配置 DB.DSN使用 yaml/env 配置(生产环境必须配置 DSN")
}
// ========== ③ 初始化 LLM 层(工厂 + 路由 + 降级) ==========
//
// 这是本次升级的核心:
// - ProviderFactory注册所有模型供应商DeepSeek/OpenAI/Azure/Ollama/Qwen
// - ModelRouter按场景名emr-generator/prescription 等)路由到对应模型
// - FallbackChain主模型挂了自动切备用
//
// 所有 Agent 通过 Router 获取模型,不直接依赖具体供应商。
llmRouter, fallback, factory := llm.InitLLM(cfg)
// 打印路由表(方便排查)
log.Println("[启动] 模型路由表:")
for scene, provider := range llmRouter.ListRoutes() {
log.Printf(" %-20s → %s", scene, provider)
}
// ========== ③ 初始化 Agent 引擎 ==========
//
// Runner 通过 llmRouter 动态获取模型:
// - 病历 Agent → 路由到 "emr-generator" → DeepSeek
// - 处方 Agent → 路由到 "prescription" → GPT-4o
//
// 如果某个模型 API 挂了FallbackChain 会自动切到备用模型。
agentRunner := agent.InitRunner(llmRouter, fallback, cfg)
// ========== ④ 初始化 HTTP 路由 ==========
r := router.Setup(agentRunner, cfg, llmRouter)
// ========== ④.5 启动药品抓取定时调度器 ==========
// 依赖 DB任务表/知识库表都在 z_xkDB 没连上时不启动,
// 面板的抓取任务页会明确报"DB 未初始化",不影响其他功能
if dao.DB != nil {
service.StartCrawlScheduler()
}
// ========== ⑤ 启动 HTTP 服务(优雅启停) ==========
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: r,
// ---------- 稳定性加固:防慢连接占死服务 ----------
// ReadHeaderTimeout读完请求头的最长时间防 Slowloris 攻击
ReadHeaderTimeout: 10 * time.Second,
// ReadTimeout读完整个请求体的最长时间enhance 请求体最大几十 KB60s 绰绰有余)
ReadTimeout: 60 * time.Second,
// WriteTimeout从读完请求到写完响应的最长时间。
// 必须大于最长的 LLM 调用链ReactLoop 多轮 + fallback 可能超过 2 分钟),
// 设 300s 兜底:超过说明彻底卡死,强制断开释放连接
WriteTimeout: 300 * time.Second,
// IdleTimeoutkeep-alive 空闲连接保留时间
IdleTimeout: 120 * time.Second,
}
go func() {
log.Printf("[启动] ✅ 中医 AI Agent 服务已启动,监听端口 :%s", cfg.Server.Port)
log.Printf("[启动] 已注册模型供应商: %v", factory.ListProviders())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[致命] 服务启动失败: %v", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("[关闭] 正在优雅关闭服务...")
// 关闭模型连接
llmRouter.Close()
// 关闭 HTTP 服务30 秒超时)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
log.Println("[关闭] ✅ 服务已安全停止")
fmt.Println("再见!")
}
// mergeDBLLMConfigs 从 DB 加载 LLM 配置,覆盖 cfg.LLM.Models
//
// 覆盖规则:
// - DB 中存在的 provider如 spark/deepseek完全替换 yaml 同名条目DB 是可信源)
// - DB 中没有的 provider如 ollama 本地):保留 yaml 中的配置
// - DB 提供 APIKey/APIURL/Modeltimeout/embedding_model 等保留 yaml 值DB 没存)
//
// 关键:额外读 xk_system_config 的全局激活组合ai_active_provider / ai_active_api_key_id /
// ai_active_model它优先级最高——后台运维跨平台指定的"激活组合"会覆盖平台默认值。
// 这是 PHP AiRuntimeConfigService::resolve() 的核心逻辑Go 端必须复刻,
// 否则会出现"后台切了平台但 Go Agent 还用 yaml 默认平台"的不一致。
func mergeDBLLMConfigs(cfg *config.Config) error {
dbCfgs, err := dao.LoadLLMConfigsFromDB(cfg.DB.EncryptKey)
if err != nil {
return err
}
for provider, db := range dbCfgs {
// 取出 yaml 已有条目(可能不存在,先准备一个空模板)
yamlCfg, exists := cfg.LLM.Models[provider]
if !exists {
yamlCfg = config.LLMConfigEx{Timeout: 120}
}
// 用 DB 值覆盖关键字段
yamlCfg.Provider = provider
yamlCfg.APIKey = db.APIKey
if db.APIURL != "" {
yamlCfg.BaseURL = db.APIURL
}
if db.Model != "" {
yamlCfg.Model = db.Model
}
cfg.LLM.Models[provider] = yamlCfg
log.Printf("[启动] ↳ DB 覆盖: %s → model=%s api_key_id=%d", provider, db.Model, db.APIKeyID)
}
// ===== 读全局激活组合,覆盖 yaml 路由表 =====
// 这一步是 Go Agent "尊重后台运维选择"的关键:
// - 后台"模型配置"Tab 保存的 ai_active_provider 会被设到 cfg.LLM.DefaultProvider
// 让 EnhanceService 在 scene 未命中路由表时回落到这个 provider而不是 yaml 写死的 deepseek
// - 后台指定的 api_key_id / model 会覆盖对应 provider 的字段,
// 保证 Go 调 LLM 时用的就是后台选中的具体 key/model
//
// 语义说明2026-08-11 重构后):
// ai_active_provider 永远是真实 providerspark/deepseek/...
// "是否走 Go Agent 中转"由 PHP 端独立开关 ai_agent_via_agent 控制,
// Go 端被调用时已经是"我要用真实模型",这里不再处理 agent 短路。
active, err := dao.LoadActiveSelection()
if err != nil {
log.Printf("[启动] ⚠️ 读取全局激活组合失败,将仅使用平台默认值: %v", err)
} else if active != nil && active.Provider != "" {
// 仅当激活的 provider 在配置中存在时才覆盖
if _, ok := cfg.LLM.Models[active.Provider]; ok {
// 覆盖默认 provider让 EnhanceService 走后台选中的平台
cfg.LLM.DefaultProvider = active.Provider
log.Printf("[启动] ↳ 全局激活: provider=%s", active.Provider)
// 用后台指定的 key/model 覆盖该 provider 的字段
if active.Model != "" || active.APIKeyID > 0 {
pCfg := cfg.LLM.Models[active.Provider]
if active.Model != "" {
pCfg.Model = active.Model
log.Printf("[启动] ↳ 激活 model 覆盖: %s → %s", active.Provider, active.Model)
}
if active.APIKeyID > 0 {
// 按 api_key_id 反查对应明文 keyDB 中存的可能是非默认 key
if plain, err := dao.GetAPIKeyByID(active.APIKeyID, cfg.DB.EncryptKey); err == nil && plain != "" {
pCfg.APIKey = plain
log.Printf("[启动] ↳ 激活 api_key_id 覆盖: %s → key_id=%d", active.Provider, active.APIKeyID)
} else if err != nil {
log.Printf("[启动] ⚠️ 取激活 api_key_id=%d 失败: %v保留平台默认 key", active.APIKeyID, err)
}
}
cfg.LLM.Models[active.Provider] = pCfg
}
}
}
return nil
}