410 lines
15 KiB
Go
410 lines
15 KiB
Go
package agentcfg
|
||
|
||
// ========================================================================
|
||
// agentcfg —— Agent 运行时配置加载器
|
||
// ========================================================================
|
||
// 职责:
|
||
// 从 z_xk 库的 xk_system_config 表读取 Agent 高级能力的开关与阈值,
|
||
// 包括 ReAct 循环、Token 预算、影子流量、A/B Test 等。
|
||
//
|
||
// 为什么要单独一个包:
|
||
// 1. 业务上这些配置属于"AI 运营策略",PHP 后台运营人员会频繁修改,
|
||
// 必须支持动态生效(缓存 TTL 60s 即可,最长 1 分钟内全集群一致)
|
||
// 2. dao 包是纯数据访问层,不该承载业务配置语义;
|
||
// agent 包又怕引入循环依赖,所以新建独立 agentcfg 包
|
||
// 3. 集中管理所有 AI 运营 key,避免散落各文件
|
||
//
|
||
// 设计要点:
|
||
// - 复用 dao.DB(已在 main.go 启动时初始化),不重复建连接池
|
||
// - 60s 内存缓存(与 dao.LoadLLMConfigsFromDB 同款 sync.RWMutex + cacheAt 模式)
|
||
// - 提供强类型 getter:GetReActConfig / GetTokenBudgetConfig / GetShadowConfig / GetABTestConfig
|
||
// - DB 不可用 / key 不存在时回落到安全默认值(不影响线上稳定性)
|
||
// ========================================================================
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"tcm-agent/internal/dao"
|
||
)
|
||
|
||
// ------------------------------------------------------------------
|
||
// 强类型配置结构
|
||
// ------------------------------------------------------------------
|
||
|
||
// ReActConfig ReAct 多轮推理循环相关配置
|
||
type ReActConfig struct {
|
||
Enabled bool // 总开关
|
||
MaxIterations int // 单任务最多 Think-Act-Observe 轮数
|
||
PlanningEnabled bool // 任务开始前是否让模型先出 Plan
|
||
ReflectionEnabled bool // 生成后是否做反思自检
|
||
ReflectionTemperature float64 // 反思步骤温度(低于生成温度,结果更稳定)
|
||
JSONRepairEnabled bool // JSON 解析失败时是否让模型修复
|
||
JSONRepairMaxRetries int // JSON 修复最多重试次数(防死循环)
|
||
}
|
||
|
||
// TokenBudgetConfig Token 预算管理配置(双层控制)
|
||
type TokenBudgetConfig struct {
|
||
Enabled bool // 总开关
|
||
PerRequest int // 单次 AI 任务总 token 上限(prompt+completion 累加)
|
||
MaxTokensPerCall int // 单次 LLM 调用输出 token 上限(透传厂商 max_tokens 字段)
|
||
}
|
||
|
||
// ShadowConfig 影子流量配置(PHP 调用时透传,Go 端只读不写)
|
||
//
|
||
// 注意:影子流量的实际分发逻辑在 PHP AiMedicalAssistService 中实现,
|
||
// Go 端读取这个配置主要是为了在 EnhanceResponse 里标记本次调用是否来自影子链路
|
||
type ShadowConfig struct {
|
||
Enabled bool // 总开关
|
||
Ratio int // 影子流量百分比 0-100
|
||
}
|
||
|
||
// ABTestConfig A/B Test 配置
|
||
type ABTestConfig struct {
|
||
Enabled bool // 总开关
|
||
ExperimentRatio int // 实验组流量百分比 0-100(命中即走 provider=agent)
|
||
}
|
||
|
||
// KBConfig 知识库源 + 检索相关配置
|
||
//
|
||
// 设计:让运营在后台一键切换"走 MaxKB"还是"走本地知识库",无需改代码
|
||
type KBConfig struct {
|
||
Source string // 知识库源:local(本地) / maxkb(MaxKB平台)
|
||
EmbeddingProvider string // 向量化 provider:noop(V1默认) / bge_m3 / aliyun / openai
|
||
EmbeddingAPIKey string // 向量化 API Key(V1 noop 不用)
|
||
TopK int // 检索返回条数(默认 5)
|
||
SimilarityThreshold float64 // 向量相似度阈值(V2 用,V1 不读)
|
||
SearchMode string // 检索模式:fulltext(V1默认) / vector(V2) / blend(V2)
|
||
}
|
||
|
||
// MedicalGuardConfig 医疗相关性前置守卫配置
|
||
//
|
||
// 业务背景:spark-lite 等轻量模型对长 prompt 角色定位能力弱,
|
||
// 如果 PHP 端组错 messages(或被滥用),Agent 会跑题输出"项目管理"等通用内容。
|
||
// 本守卫在 Enhance 入口拦截非医疗请求,节省 token 并防止滥用。
|
||
//
|
||
// 运维策略:
|
||
// - 默认开启(true),可在 xk_system_config 中关闭以兼容特殊场景
|
||
// - 拦截后的请求会返回 error,PHP 端会得到 503/业务错误码
|
||
type MedicalGuardConfig struct {
|
||
Enabled bool // 总开关(默认 true)
|
||
}
|
||
|
||
// DebugConfig 调试日志配置
|
||
//
|
||
// 业务背景:排查厂商 API 错误(如讯飞 10003 消息格式错误)时需要看完整请求体,
|
||
// 但请求体包含患者病历(PHI 隐私数据),生产环境不能常开。
|
||
//
|
||
// 运维策略:
|
||
// - 默认关闭(false),排查问题时通过 xk_system_config 的 ai_agent_debug_log 临时打开
|
||
// - 打开后 LLM 客户端会打印完整请求体(截断到 1500 字符)
|
||
// - 错误响应体日志不受本开关控制(始终打印,错误响应不含患者数据)
|
||
type DebugConfig struct {
|
||
LogRequestBody bool // 是否打印 LLM 请求体(含患者隐私,生产默认关闭)
|
||
}
|
||
|
||
// AllConfig 全部 Agent 配置的聚合视图(一次性读出来传给 Enhancer)
|
||
type AllConfig struct {
|
||
ReAct ReActConfig
|
||
TokenBudget TokenBudgetConfig
|
||
Shadow ShadowConfig
|
||
ABTest ABTestConfig
|
||
KB KBConfig
|
||
MedicalGuard MedicalGuardConfig
|
||
Debug DebugConfig
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 默认值(DB 不可用 / key 不存在时回落)
|
||
// ------------------------------------------------------------------
|
||
//
|
||
// 默认值策略:保守优先
|
||
// - ReAct / Planning / Reflection:默认关(避免线上突然多轮调用拉高延迟和成本)
|
||
// - JSONRepair:默认开(成本低、显著提升可用性)
|
||
// - TokenBudget:默认开 + 单任务 8000(防止 Agent 死循环烧 token)
|
||
// - MaxTokensPerCall:默认 2048(单次响应足够,留出预算给后续轮次)
|
||
// - Shadow / ABTest:默认全关(流量实验必须显式开启)
|
||
|
||
var defaultConfig = AllConfig{
|
||
ReAct: ReActConfig{
|
||
Enabled: false,
|
||
MaxIterations: 3,
|
||
PlanningEnabled: false,
|
||
ReflectionEnabled: false,
|
||
ReflectionTemperature: 0.2,
|
||
JSONRepairEnabled: true,
|
||
JSONRepairMaxRetries: 2,
|
||
},
|
||
TokenBudget: TokenBudgetConfig{
|
||
Enabled: true,
|
||
PerRequest: 8000,
|
||
MaxTokensPerCall: 2048,
|
||
},
|
||
Shadow: ShadowConfig{
|
||
Enabled: false,
|
||
Ratio: 0,
|
||
},
|
||
ABTest: ABTestConfig{
|
||
Enabled: false,
|
||
ExperimentRatio: 10,
|
||
},
|
||
KB: KBConfig{
|
||
Source: "local", // V1 默认本地(避免依赖 MaxKB Pro)
|
||
EmbeddingProvider: "noop",
|
||
EmbeddingAPIKey: "",
|
||
TopK: 5,
|
||
SimilarityThreshold: 0.5,
|
||
SearchMode: "fulltext",
|
||
},
|
||
MedicalGuard: MedicalGuardConfig{
|
||
// 默认开启:Go Agent 定位是医疗专用服务,非医疗请求直接拦截
|
||
// 若特殊场景需要放行(如内部联调测试),可在 xk_system_config 里关闭
|
||
Enabled: true,
|
||
},
|
||
Debug: DebugConfig{
|
||
// 默认关闭:请求体含患者隐私,只在排查厂商 API 错误时临时打开
|
||
LogRequestBody: false,
|
||
},
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 全局缓存(仿 dao 包 cfgCacheMutex 模式)
|
||
// ------------------------------------------------------------------
|
||
|
||
const (
|
||
cacheTTL = 60 * time.Second // 缓存 60 秒,最长 1 分钟内全集群一致
|
||
)
|
||
|
||
var (
|
||
cacheMu sync.RWMutex
|
||
cacheData *AllConfig
|
||
cacheAt time.Time
|
||
)
|
||
|
||
// ------------------------------------------------------------------
|
||
// 对外暴露的方法
|
||
// ------------------------------------------------------------------
|
||
|
||
// Get 获取全部 Agent 配置(命中缓存直接返回,否则从 DB 重新加载)
|
||
//
|
||
// 调用方:EnhancerService.Enhance / ShadowJob 等
|
||
func Get() *AllConfig {
|
||
cacheMu.RLock()
|
||
if cacheData != nil && time.Since(cacheAt) < cacheTTL {
|
||
out := *cacheData // 拷贝一份,避免调用方误改全局缓存
|
||
cacheMu.RUnlock()
|
||
return &out
|
||
}
|
||
cacheMu.RUnlock()
|
||
|
||
// 缓存未命中或已过期,加写锁重新加载(double-check 模式避免重复加载)
|
||
cacheMu.Lock()
|
||
defer cacheMu.Unlock()
|
||
if cacheData != nil && time.Since(cacheAt) < cacheTTL {
|
||
out := *cacheData
|
||
return &out
|
||
}
|
||
|
||
loaded := loadFromDB()
|
||
cacheData = loaded
|
||
cacheAt = time.Now()
|
||
return loaded
|
||
}
|
||
|
||
// Invalidate 主动失效缓存
|
||
//
|
||
// 使用场景:PHP 后台改完配置后,可以调一个 admin/agent/invalidate-cache 接口
|
||
// 让 Go 端立即重新读取(避免等 60s)
|
||
func Invalidate() {
|
||
cacheMu.Lock()
|
||
cacheData = nil
|
||
cacheAt = time.Time{}
|
||
cacheMu.Unlock()
|
||
log.Printf("[agentcfg] 缓存已主动失效")
|
||
}
|
||
|
||
// GetReActConfig 便捷方法:只取 ReAct 配置
|
||
func GetReActConfig() ReActConfig { return Get().ReAct }
|
||
|
||
// GetTokenBudgetConfig 便捷方法:只取 Token 预算配置
|
||
func GetTokenBudgetConfig() TokenBudgetConfig { return Get().TokenBudget }
|
||
|
||
// GetShadowConfig 便捷方法:只取影子流量配置
|
||
func GetShadowConfig() ShadowConfig { return Get().Shadow }
|
||
|
||
// GetABTestConfig 便捷方法:只取 A/B Test 配置
|
||
func GetABTestConfig() ABTestConfig { return Get().ABTest }
|
||
|
||
// ------------------------------------------------------------------
|
||
// 从 DB 加载实现
|
||
// ------------------------------------------------------------------
|
||
|
||
// loadFromDB 从 xk_system_config 表读取所有 ai_xxx key 并装配成 AllConfig
|
||
//
|
||
// 容错策略:
|
||
// - dao.DB 未初始化 → 返回 defaultConfig(启动顺序问题不应让业务挂)
|
||
// - DB 查询失败 → 返回 defaultConfig + 打日志(DB 抖动不应阻断 AI 生成)
|
||
// - 单个 key 缺失或解析失败 → 用 defaultConfig 里对应字段的值(不影响其他 key)
|
||
func loadFromDB() *AllConfig {
|
||
cfg := defaultConfig // 拷贝默认值,逐字段覆盖
|
||
|
||
if dao.DB == nil {
|
||
log.Printf("[agentcfg] dao.DB 未初始化,回落默认配置")
|
||
return &cfg
|
||
}
|
||
|
||
// 一次性查所有 ai_ 开头的配置项(避免多次往返 DB)
|
||
rows, err := fetchAIConfigRows()
|
||
if err != nil {
|
||
log.Printf("[agentcfg] 查询 xk_system_config 失败,回落默认配置: %v", err)
|
||
return &cfg
|
||
}
|
||
|
||
// 逐 key 覆盖(缺失的 key 保持默认值,不报错)
|
||
applyKey(&cfg.ReAct.Enabled, rows, "ai_react_enabled", parseBool)
|
||
applyKey(&cfg.ReAct.MaxIterations, rows, "ai_react_max_iterations", parseInt)
|
||
applyKey(&cfg.ReAct.PlanningEnabled, rows, "ai_react_planning_enabled", parseBool)
|
||
applyKey(&cfg.ReAct.ReflectionEnabled, rows, "ai_react_reflection_enabled", parseBool)
|
||
applyKey(&cfg.ReAct.ReflectionTemperature, rows, "ai_react_reflection_temperature", parseFloat)
|
||
applyKey(&cfg.ReAct.JSONRepairEnabled, rows, "ai_react_json_repair_enabled", parseBool)
|
||
|
||
applyKey(&cfg.TokenBudget.Enabled, rows, "ai_token_budget_enabled", parseBool)
|
||
applyKey(&cfg.TokenBudget.PerRequest, rows, "ai_token_budget_per_request", parseInt)
|
||
applyKey(&cfg.TokenBudget.MaxTokensPerCall, rows, "ai_token_max_per_call", parseInt)
|
||
|
||
applyKey(&cfg.Shadow.Enabled, rows, "ai_shadow_traffic_enabled", parseBool)
|
||
applyKey(&cfg.Shadow.Ratio, rows, "ai_shadow_traffic_ratio", parseInt)
|
||
|
||
applyKey(&cfg.ABTest.Enabled, rows, "ai_ab_test_enabled", parseBool)
|
||
applyKey(&cfg.ABTest.ExperimentRatio, rows, "ai_ab_test_experiment_ratio", parseInt)
|
||
|
||
// KB 配置
|
||
applyKey(&cfg.KB.Source, rows, "ai_kb_source", parseString)
|
||
applyKey(&cfg.KB.EmbeddingProvider, rows, "ai_kb_embedding_provider", parseString)
|
||
applyKey(&cfg.KB.EmbeddingAPIKey, rows, "ai_kb_embedding_api_key", parseString)
|
||
applyKey(&cfg.KB.TopK, rows, "ai_kb_top_k", parseInt)
|
||
applyKey(&cfg.KB.SimilarityThreshold, rows, "ai_kb_similarity_threshold", parseFloat)
|
||
applyKey(&cfg.KB.SearchMode, rows, "ai_kb_search_mode", parseString)
|
||
|
||
// 医疗守卫:默认开启,运维可通过 ai_agent_medical_guard 关闭(联调/测试场景)
|
||
applyKey(&cfg.MedicalGuard.Enabled, rows, "ai_agent_medical_guard", parseBool)
|
||
|
||
// 调试日志:默认关闭(请求体含患者隐私),排查厂商 API 错误时临时打开
|
||
applyKey(&cfg.Debug.LogRequestBody, rows, "ai_agent_debug_log", parseBool)
|
||
|
||
// 业务校验:MaxIterations 必须 >= 1,避免循环跑不起来
|
||
if cfg.ReAct.Enabled && cfg.ReAct.MaxIterations < 1 {
|
||
cfg.ReAct.MaxIterations = 1
|
||
}
|
||
// Ratio 必须 0-100
|
||
if cfg.Shadow.Ratio < 0 {
|
||
cfg.Shadow.Ratio = 0
|
||
} else if cfg.Shadow.Ratio > 100 {
|
||
cfg.Shadow.Ratio = 100
|
||
}
|
||
if cfg.ABTest.ExperimentRatio < 0 {
|
||
cfg.ABTest.ExperimentRatio = 0
|
||
} else if cfg.ABTest.ExperimentRatio > 100 {
|
||
cfg.ABTest.ExperimentRatio = 100
|
||
}
|
||
// TokenBudget 上限必须 >= MaxTokensPerCall,否则单次调用就能撑爆预算
|
||
if cfg.TokenBudget.Enabled && cfg.TokenBudget.PerRequest < cfg.TokenBudget.MaxTokensPerCall {
|
||
cfg.TokenBudget.PerRequest = cfg.TokenBudget.MaxTokensPerCall
|
||
}
|
||
// KB 配置容错:未知 source / mode 回落默认值
|
||
if cfg.KB.Source != "local" && cfg.KB.Source != "maxkb" {
|
||
cfg.KB.Source = "local"
|
||
}
|
||
if cfg.KB.SearchMode != "fulltext" && cfg.KB.SearchMode != "vector" && cfg.KB.SearchMode != "blend" {
|
||
cfg.KB.SearchMode = "fulltext"
|
||
}
|
||
if cfg.KB.TopK <= 0 {
|
||
cfg.KB.TopK = 5
|
||
}
|
||
|
||
return &cfg
|
||
}
|
||
|
||
// fetchAIConfigRows 一次性查所有 ai_ 开头的配置项,返回 map[key]value
|
||
//
|
||
// 为什么不用 dao.SystemConfigRow 单查每个 key:
|
||
// 一次 IN 查询只往返 1 次 DB,性能远好于按 key 逐个 First
|
||
func fetchAIConfigRows() (map[string]string, error) {
|
||
var rows []dao.SystemConfigRow
|
||
// 用 LIKE 一次拉所有 ai_ 前缀的 key(共 13 个,几乎不占带宽)
|
||
err := dao.DB.
|
||
Select("config_key, config_value").
|
||
Where("config_key LIKE ?", "ai\\_%"). // _ 是 SQL 通配符,需转义
|
||
Find(&rows).Error
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query xk_system_config: %w", err)
|
||
}
|
||
|
||
out := make(map[string]string, len(rows))
|
||
for _, r := range rows {
|
||
out[r.ConfigKey] = r.ConfigValue
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// applyKey 通用的"按 key 从 map 取值并赋给目标字段"工具方法
|
||
//
|
||
// 泛型 T 是目标字段类型(bool/int/float64);
|
||
// parser 负责把字符串值解析成 T,解析失败或 key 缺失时保持 *target 原值不变
|
||
func applyKey[T any](target *T, rows map[string]string, key string, parser func(string) (T, bool)) {
|
||
if val, ok := rows[key]; ok {
|
||
if parsed, ok2 := parser(val); ok2 {
|
||
*target = parsed
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 字符串解析工具(与 PHP SystemConfigService::castValue 对齐)
|
||
// ------------------------------------------------------------------
|
||
|
||
// parseBool 解析 bool:与 PHP castValue 一致,接受 1/true/yes(不区分大小写)
|
||
func parseBool(s string) (bool, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "1", "true", "yes":
|
||
return true, true
|
||
case "0", "false", "no", "":
|
||
return false, true
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
// parseInt 解析 int:失败返回 (0, false) 让调用方保持原值
|
||
func parseInt(s string) (int, bool) {
|
||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
// parseFloat 解析 float64
|
||
func parseFloat(s string) (float64, bool) {
|
||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return f, true
|
||
}
|
||
|
||
// parseString 解析 string:去首尾空格;空串视为不存在(保持默认)
|
||
func parseString(s string) (string, bool) {
|
||
t := strings.TrimSpace(s)
|
||
if t == "" {
|
||
return "", false
|
||
}
|
||
return t, true
|
||
}
|
||
|
||
// GetKBConfig 便捷方法:只取知识库配置
|
||
func GetKBConfig() KBConfig { return Get().KB }
|