248 lines
10 KiB
Go
248 lines
10 KiB
Go
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
|
||
}
|