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

410 lines
15 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 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 }