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_xk):DB 没连上时不启动, // 面板的抓取任务页会明确报"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 请求体最大几十 KB,60s 绰绰有余) ReadTimeout: 60 * time.Second, // WriteTimeout:从读完请求到写完响应的最长时间。 // 必须大于最长的 LLM 调用链(ReactLoop 多轮 + fallback 可能超过 2 分钟), // 设 300s 兜底:超过说明彻底卡死,强制断开释放连接 WriteTimeout: 300 * time.Second, // IdleTimeout:keep-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/Model,timeout/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 永远是真实 provider(spark/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 反查对应明文 key(DB 中存的可能是非默认 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 }