配置驱动:让Agent灵活适配不同场景 — 硬编码是Agent的敌人,配置驱动让同一个Agent服务100个赛道

先说结论

早期赛道、格式、风格全硬编码在代码里 --- 加一个"北漂"赛道要改 3 个文件:Python 枚举加一个值、前端 JS 加一个选项、人设模板加一个文件。改完还要重启服务。

在 self-media-agent 项目里,最终演进为 三层配置体系

arduino 复制代码
config/
├── default.yaml          全局默认配置(LLM、存储、质检、缓存...)
├── options.yaml          选项列表(赛道、类型、格式、风格)
└── personas/
    ├── beauty_gentle.yaml    美妆温柔干货人设
    └── workplace_sharp.yaml  职场犀利测评人设

同一套代码,加载不同配置文件,就能服务美妆、职场、育儿、健身、北漂...任意赛道。加一个新赛道,只需在 options.yaml 加一行,前端自动从 API 获取,不用改任何代码。

配置层 职责 对应代码
全局配置 LLM、存储、缓存等系统级参数 config/default.yaml + config/models.py
人设模板 赛道、风格、格式等业务级参数 config/personas/*.yaml
选项列表 可选值的枚举(赛道有哪些、格式有哪些) config/options.yaml + api/routes/options.py

硬编码是 Agent 的敌人 --- 配置驱动让 Agent 从"为一个赛道写的工具"变成"为所有赛道写的平台"。

一、为什么需要配置驱动?

硬编码时代:加一个赛道改 3 个文件

bash 复制代码
想加"北漂"赛道 →
  ① persona/schema.py 的 Niche 枚举加一个值
  ② 前端 JS 的赛道下拉框加一个选项
  ③ config/personas/ 加一个北漂人设模板
  → 改了 3 个地方,其中 2 个是代码,要重新部署
  → 而且每个新人来都要问"赛道在哪里加?"

硬编码的问题

  1. 加赛道要改代码 --- 改代码 = 走开发流程 = 提 PR = 审代码 = 部署 = 重启
  2. 前后端不同步 --- 后端加了"北漂"枚举,前端 JS 忘了加,用户创建人设时选不到
  3. 无法运行时扩展 --- 想加"考研"赛道,必须停机改代码重启
  4. 配置散落 --- LLM 的 temperature 写在代码里,改一下要找到调用处
ini 复制代码
# ❌ 硬编码:赛道写死在枚举里
class Niche(str, Enum):
    BEAUTY = "beauty"
    CAREER = "career"
    # 想加"北漂"?改这里 → 改完要重启
​
# ❌ 硬编码:temperature 写死在调用处
llm.generate(prompt, temperature=0.7)  # 改温度要找到这行代码

配置驱动的目标

bash 复制代码
想加"北漂"赛道 →
  ① 在 options.yaml 加一行(或通过 API 添加)
  → 前端自动从 /api/options/niches 获取,下拉框自动出现"北漂"
  → 不用改任何代码,不用重启

配置驱动的三个原则

  1. 代码不包含业务参数 --- 赛道、风格、温度、阈值全在配置文件
  2. 配置可运行时修改 --- 通过 API 增删选项,不用重启
  3. 敏感信息走环境变量 --- API Key 不入配置文件,不入 Git

二、三层配置体系

配置的三个层次

arduino 复制代码
                    ┌─────────────────────────────────┐
                    │         代码(不变的部分)         │
                    │  配置加载逻辑 + 业务逻辑 + 路由    │
                    └──────────────┬──────────────────┘
                                   │ 加载
                    ┌──────────────▼──────────────────┐
                    │      配置(变的部分)             │
                    │                                  │
                    │  default.yaml    全局系统配置     │
                    │  options.yaml    选项枚举列表     │
                    │  personas/*.yaml 人设模板        │
                    └──────────────┬──────────────────┘
                                   │ 引用
                    ┌──────────────▼──────────────────┐
                    │      环境变量(敏感信息)         │
                    │  API_KEY、数据库密码...          │
                    └─────────────────────────────────┘

三层各管各的事

什么时候变 谁来改 改完要重启吗
全局配置 换 LLM、调缓存策略 开发者
人设模板 新增赛道、调整风格 用户/运营 不要(API 热加载)
选项列表 新增赛道选项 用户/运营 不要(API 热加载)
环境变量 换 API Key 运维 要(但不用改代码)

关键区分:全局配置是"系统级"的(改了影响所有用户),人设和选项是"业务级"的(改了只影响特定赛道)。系统级配置走文件 + 重启,业务级配置走 API + 热加载。

三、全局配置:default.yaml

一个文件管所有系统参数

yaml 复制代码
# config/default.yaml
​
llm:
  base_url: "https://open.bigmodel.cn/api/paas/v4/"
  api_key: "333448406f2640f1a870ccac1e50bc3e.xr5M2NXQGQnJlDGe"
  model: "glm-4-flash"
  temperature: 0.7
  max_tokens: 4096
​
storage:
  mode: "persist"
  persist_dir: "data/store"
  persist_format: "yaml"
​
output:
  dir: "data/output"
  format: "markdown"
​
quality:
  sensitive_words_file: null
  auto_fix: true
  dedup_enabled: true
  dedup_threshold: 0.85
  colloquial_enabled: true
  logic_check_enabled: false
​
cache:
  enabled: true
  max_size: 2000
  default_ttl: 300
  hotspot_ttl: 600
  topic_ttl: 1800
  content_ttl: 0
​
task:
  max_concurrent: 4
  default_timeout: 300
​
web:
  host: "localhost"
  port: 8088
  reload: false
​
hotspot:
  enabled: true
  platforms:
    - "xiaohongshu"
    - "douyin"
    - "weibo"
  cache_ttl: 600
  top_k: 20

8 个配置块,各管一个子系统

配置块 管什么 典型调整场景
llm 模型调用 换模型、调温度、改 token 上限
storage 数据存储 切换内存/持久化模式
output 文件输出 改输出目录、换格式
quality 质检系统 开关检查项、调阈值
cache 缓存策略 调 TTL、改最大容量
task 异步任务 调并发数、改超时
web Web 服务 改端口、开关热重载
hotspot 热点抓取 开关平台、调缓存时间

一个文件改完所有系统参数 --- 不用在代码里到处找 temperature=0.7 改成 0.8,直接在 YAML 里改,重启生效。

Pydantic 模型:类型校验 + 默认值

python 复制代码
# config/models.py
​
class LLMConfig(BaseModel):
    """LLM 调用配置"""
​
    base_url: str = "https://api.deepseek.com/v1"
    api_key: str = ""
    model: str = "deepseek-chat"
    temperature: float = 0.7
    max_tokens: int = 4096
​
    @field_validator("temperature")
    @classmethod
    def validate_temperature(cls, v: float) -> float:
        if not 0.0 <= v <= 2.0:
            raise ValueError("temperature 必须在 0.0 ~ 2.0 之间")
        return v
​
    @field_validator("max_tokens")
    @classmethod
    def validate_max_tokens(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("max_tokens 必须为正整数")
        return v

每个配置块对应一个 Pydantic 模型 --- 类型校验、默认值、值域约束,全由 Pydantic 保证。

注意 field_validator --- temperature 必须在 0.0~2.0 之间,max_tokens 必须为正整数。配置写错了,启动时就报错,不会等到运行时才发现。

bash 复制代码
# 如果 default.yaml 里写了 temperature: 3.0
# 启动时直接报错:
# ValidationError: temperature 必须在 0.0 ~ 2.0 之间

这就是"快速失败" --- 配置错了立刻知道,而不是生成内容时才发现温度不对。

嵌套配置:AppConfig 组装所有子配置

ini 复制代码
# config/models.py

class AppConfig(BaseModel):
    """应用全局配置"""

    llm: LLMConfig = Field(default_factory=LLMConfig)
    storage: StorageConfig = Field(default_factory=StorageConfig)
    output: OutputConfig = Field(default_factory=OutputConfig)
    quality: QualityConfig = Field(default_factory=QualityConfig)
    cache: CacheConfig = Field(default_factory=CacheConfig)
    task: TaskConfig = Field(default_factory=TaskConfig)
    web: WebConfig = Field(default_factory=WebConfig)
    hotspot: HotspotConfig = Field(default_factory=HotspotConfig)

AppConfig 是所有子配置的容器 --- 每个子配置都有 default_factory,意味着即使 YAML 文件里没写某个块,也有默认值兜底。

makefile 复制代码
# 如果 default.yaml 只写了这些:
llm:
  model: "glm-4-flash"

# 其他配置块(storage, cache, quality...)全用默认值
# 不会因为少写了配置而崩溃

好处:配置文件可以只写"想改的部分",其他全用默认值。新加一个配置块,旧配置文件不用改。

四、人设模板:同一套代码,不同赛道

两个人设模板的对比

vbnet 复制代码
# config/personas/beauty_gentle.yaml --- 美妆温柔干货

name: "美妆温柔干货"
niche: "beauty"
persona_type: "gentle_expert"
writing_style: "casual"
content_format: "xiaohongshu"

opening_template: "姐妹们~今天来聊聊{topic},这个问题真的太多人问了!"
closing_template: "希望对你们有帮助呀~有问题评论区见💕"
emoji_frequency: "medium"
paragraph_max_chars: 150
line_spacing: "normal"

banned_words:
  - "最便宜"
  - "世界第一"
  - "100%有效"
no_exaggeration: true
no_vulgar: true
no_fake_data: true

model_name: "deepseek-chat"
temperature: 0.8
vbnet 复制代码
# config/personas/workplace_sharp.yaml --- 职场犀利测评

name: "职场犀利测评"
niche: "career"
persona_type: "sharp_reviewer"
writing_style: "sharp"
content_format: "long_article"

opening_template: ""
closing_template: "觉得有用就点赞收藏,下期继续聊职场那些事🔥"
emoji_frequency: "low"
paragraph_max_chars: 200
line_spacing: "normal"

banned_words:
  - "绝对"
  - "一定"
no_exaggeration: true
no_vulgar: true
no_fake_data: true

model_name: "deepseek-chat"
temperature: 0.7

同一个人设 Schema,不同的配置值,产出完全不同的内容

配置项 美妆温柔 职场犀利 区别
niche beauty career 赛道不同
persona_type gentle_expert sharp_reviewer 人设类型不同
writing_style casual sharp 文风不同
content_format xiaohongshu long_article 输出格式不同
opening_template "姐妹们~..." ""(空) 开头风格不同
emoji_frequency medium low emoji 用量不同
paragraph_max_chars 150 200 段落长度不同
banned_words 最便宜、世界第一 绝对、一定 禁忌词不同
temperature 0.8 0.7 生成多样性不同

这就是配置驱动的威力 --- 同一套 BodyGenerator 代码,加载 beauty_gentle.yaml 产出小红书风格的种草文,加载 workplace_sharp.yaml 产出公众号风格的职场干货。

人设的数据模型

ini 复制代码
# persona/schema.py

class PersonaConfig(BaseModel):
    """人设 & 赛道配置 --- 一次设定永久生效"""

    id: str = Field(default="", description="唯一标识,自动生成")
    name: str = Field(..., description="人设名称")
    niche: str = Field(default=Niche.BEAUTY, description="垂直赛道")
    persona_type: str = Field(default=PersonaType.GENTLE_EXPERT, description="人设类型")
    writing_style: str = Field(default=WritingStyle.CASUAL, description="文案风格")
    content_format: str = Field(default=ContentFormat.XIAOHONGSHU, description="内容形式")

    # 格式模板
    opening_template: str = Field(default="", description="开头模板")
    closing_template: str = Field(default="", description="结尾引导模板")
    emoji_frequency: str = Field(default=EmojiFrequency.MEDIUM, description="emoji 频率")
    paragraph_max_chars: int = Field(default=150, description="段落最大字数")
    line_spacing: str = Field(default=LineSpacing.NORMAL, description="排版间距")

    # 禁忌规则
    banned_words: list[str] = Field(default_factory=list, description="禁用词列表")
    no_exaggeration: bool = Field(default=True, description="禁止夸大宣传")
    no_vulgar: bool = Field(default=True, description="禁止低俗")
    no_fake_data: bool = Field(default=True, description="禁止虚假数据")

    # LLM 参数
    model_name: str = Field(default="deepseek-chat", description="使用的模型")
    temperature: float = Field(default=0.7, description="生成温度")

    # 风格进化(从修改中学习)
    style_profile: str = Field(default="", description="风格画像摘要(LLM 生成,自动进化)")
    style_preferences: list[dict] = Field(default_factory=list, description="结构化风格偏好列表")

PersonaConfig 有 20+ 个字段,分成 5 组:

复制代码
① 基础信息:name, niche, persona_type, writing_style, content_format
② 格式模板:opening_template, closing_template, emoji_frequency, paragraph_max_chars, line_spacing
③ 禁忌规则:banned_words, no_exaggeration, no_vulgar, no_fake_data
④ LLM 参数:model_name, temperature
⑤ 风格进化:style_profile, style_preferences(运行时自动填充)

①~④ 由配置文件(YAML)设定,⑤ 由系统运行时自动填充 --- 用户设定一次人设,系统在运行中不断学习风格偏好,写回 style_profilestyle_preferences。这就是第 9 篇讲的"风格进化"。

人设加载:从 YAML 到 PersonaConfig

python 复制代码
# config/loader.py
def load_persona_config(persona_path: str | Path) -> dict:
    """加载人设 YAML 配置文件(原始字典,由 PersonaManager 解析)"""
    persona_path = Path(persona_path)
    if not persona_path.exists():
        raise FileNotFoundError(f"人设配置文件不存在: {persona_path}")

    with open(persona_path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}

    return _resolve_dict_env_vars(data)

加载流程

javascript 复制代码
beauty_gentle.yaml
    ↓ yaml.safe_load()
{"name": "美妆温柔干货", "niche": "beauty", ...}
    ↓ _resolve_dict_env_vars()
{"name": "美妆温柔干货", "niche": "beauty", ...}  # 替换环境变量
    ↓ PersonaConfig(**data)
PersonaConfig(name="美妆温柔干货", niche="beauty", ...)
    ↓ PersonaManager.create()
存入 Repository,后续生成内容时使用

YAML → dict → PersonaConfig --- 三步走,中间经过环境变量替换,最后由 Pydantic 校验类型。

CLI 加载人设

bash 复制代码
# 从预置模板加载
sma persona load config/personas/beauty_gentle.yaml

# 交互式创建
sma persona init

两种创建人设的方式

  1. 从模板加载 --- persona load <yaml>,读 YAML 文件,适合批量预设
  2. 交互式创建 --- persona init,一步步问用户,适合自定义

两种方式最终都生成一个 PersonaConfig 对象,存入 Repository。之后的生成流程完全一样 --- 不关心人设是从 YAML 加载的还是交互式创建的。

五、选项动态加载:options.yaml + API

选项配置:可枚举的值域

yaml 复制代码
# config/options.yaml

niches:                    # 赛道
- key: beauty
  label: 美妆
  emoji: 🌸
  builtin: true
- key: career
  label: 职场
  emoji: 💼
  builtin: true
- key: 北漂
  label: 北漂
  emoji: 🏙️
  builtin: false          # 用户自定义的
- key: 劳务派遣
  label: 劳务派遣
  emoji: ⚖️
  builtin: false          # 用户自定义的

types:                     # 人设类型
- key: gentle_expert
  label: 温柔干货派
  builtin: true
- key: sharp_reviewer
  label: 犀利测评派
  builtin: true

formats:                   # 内容格式
- key: xiaohongshu
  label: 小红书图文
  builtin: true
- key: short_video
  label: 短视频脚本
  builtin: true

styles:                    # 文案风格
- key: casual
  label: 轻松口语
  builtin: true
- key: professional
  label: 专业严谨
  builtin: true

四个分类,每个分类一组选项

分类 含义 例子
niches 垂直赛道 美妆、职场、北漂、劳务派遣
types 人设类型 温柔干货派、犀利测评派
formats 内容格式 小红书图文、短视频脚本
styles 文案风格 轻松口语、专业严谨

每个选项有三个字段

yaml 复制代码
- key: beauty        # 值(存到 PersonaConfig.niche)
  label: 美妆         # 显示名(前端下拉框显示)
  emoji: 🌸           # 图标(前端展示)
  builtin: true       # 是否系统预设(预设的不可删除)

注意 builtin 字段 --- true 是系统预设的(不可删除),false 是用户自定义的(可删除)。"北漂"和"劳务派遣"就是用户后期通过 API 添加的自定义赛道。

选项 API:运行时增删

python 复制代码
# api/routes/options.py

router = APIRouter()

# 配置文件路径
_OPTIONS_FILE = Path(__file__).resolve().parents[4] / "config" / "options.yaml"


def _load_options() -> dict[str, list[dict[str, Any]]]:
    """加载选项配置"""
    if not _OPTIONS_FILE.exists():
        return {}
    with open(_OPTIONS_FILE, encoding="utf-8") as f:
        return yaml.safe_load(f) or {}


def _save_options(data: dict[str, list[dict[str, Any]]]) -> None:
    """保存选项配置"""
    _OPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
    with open(_OPTIONS_FILE, "w", encoding="utf-8") as f:
        yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)

选项配置直接读写 YAML 文件 --- 不走数据库,因为选项数据量小、变更频率低、需要持久化但不需要复杂查询。YAML 文件就是最简单的"数据库"。

四个 API 端点

python 复制代码
@router.get("")
async def list_options() -> dict:
    """获取所有选项分类"""
    data = _load_options()
    return {"success": True, "data": data}

@router.get("/{category}")
async def list_category(category: str) -> dict:
    """获取某个分类的选项列表"""
    data = _load_options()
    if category not in data:
        raise HTTPException(status_code=404, detail=f"分类 {category} 不存在")
    return {"success": True, "data": data[category]}

@router.post("/{category}")
async def add_option(category: str, req: OptionAddRequest) -> dict:
    """添加自定义选项"""
    data = _load_options()
    if category not in data:
        data[category] = []
    # 检查 key 是否已存在
    for item in data[category]:
        if item["key"] == req.key:
            raise HTTPException(status_code=400, detail=f"key '{req.key}' 已存在")
    data[category].append({"key": req.key, "label": req.label, "builtin": False})
    _save_options(data)
    return {"success": True, "message": "添加成功"}

@router.delete("/{category}/{key}")
async def delete_option(category: str, key: str) -> dict:
    """删除自定义选项(系统预设不可删除)"""
    data = _load_options()
    if category not in data:
        raise HTTPException(status_code=404, detail=f"分类 {category} 不存在")
    for i, item in enumerate(data[category]):
        if item["key"] == key:
            if item.get("builtin", False):
                raise HTTPException(status_code=400, detail="系统预设选项不可删除")
            data[category].pop(i)
            _save_options(data)
            return {"success": True, "message": "删除成功"}
    raise HTTPException(status_code=404, detail=f"选项 '{key}' 不存在")

四个端点,完整的 CRUD

端点 方法 作用
/api/options GET 获取所有分类的所有选项
/api/options/{category} GET 获取某个分类的选项列表
/api/options/{category} POST 添加自定义选项
/api/options/{category}/{key} DELETE 删除自定义选项

注意删除时的保护

python 复制代码
if item.get("builtin", False):
    raise HTTPException(status_code=400, detail="系统预设选项不可删除")

系统预设的选项(builtin: true)不可删除 --- 防止误删基础选项导致系统不可用。用户自定义的(builtin: false)可以随时删。

前端动态加载:不再硬编码

ini 复制代码
❌ 硬编码时代(前端 JS):
const NICHES = [
    {key: "beauty", label: "美妆"},
    {key: "career", label: "职场"},
    // 想加"北漂"?改这里 → 改完要重新部署前端
];

✅ 配置驱动时代(前端 JS):
const niches = await fetch("/api/options/niches").then(r => r.json());
// 赛道列表从 API 获取,后端加了前端自动有

前端不再维护选项列表 --- 页面加载时从 /api/options 获取所有选项,渲染下拉框。后端通过 API 加了"北漂"赛道,前端刷新页面就自动出现,不用改前端代码。

bash 复制代码
加"北漂"赛道的完整流程:
  ① POST /api/options/niches  {"key": "北漂", "label": "北漂"}
  → options.yaml 自动写入
  ② 前端刷新页面,下拉框自动出现"北漂"
  ③ 用户选"北漂"创建人设,生成内容
  → 全程不用改任何代码,不用重启

六、环境变量:敏感信息不入配置文件

问题:API Key 写在 YAML 里

makefile 复制代码
# config/default.yaml
llm:
  api_key: "333448406f2640f1a870ccac1e50bc3e.xr5M2NXQGQnJlDGe"  # ← 提交到 Git 了!

API Key 写在配置文件里,一旦提交到 Git,就泄露了 --- 任何人都能看到,而且 Git 历史里永远留着。

解决:环境变量引用

python 复制代码
# config/loader.py

# 匹配 ${ENV_VAR} 或 ${ENV_VAR:default} 格式
_ENV_PATTERN = re.compile(r"${([^}:]+)(?::([^}]*))?}")


def _resolve_env_vars(value: str) -> str:
    """递归替换字符串中的环境变量引用"""

    def _replacer(match: re.Match) -> str:
        var_name = match.group(1)
        default = match.group(2)  # 可能为 None
        env_value = os.environ.get(var_name)
        if env_value is not None:
            return env_value
        if default is not None:
            return default
        logger.warning(f"环境变量 {var_name} 未设置且无默认值,保留原样")
        return match.group(0)

    return _ENV_PATTERN.sub(_replacer, value)

正则匹配 ${...} 格式 --- 配置文件里写 ${ENV_VAR}${ENV_VAR:default},加载时自动替换成环境变量的值。

perl 复制代码
# config/default.yaml --- 安全版本
llm:
  api_key: "${DEEPSEEK_API_KEY}"              # 从环境变量读取
  base_url: "${LLM_BASE_URL:https://api.deepseek.com/v1}"  # 有默认值
ini 复制代码
# 环境变量设置
export DEEPSEEK_API_KEY="sk-xxx"

加载时的替换过程

dart 复制代码
YAML 原始值: "${DEEPSEEK_API_KEY}"
    ↓ 正则匹配
var_name = "DEEPSEEK_API_KEY", default = None
    ↓ os.environ.get("DEEPSEEK_API_KEY")
"sk-xxx"
    ↓ 替换
最终值: "sk-xxx"

有默认值的情况

dart 复制代码
YAML 原始值: "${LLM_BASE_URL:https://api.deepseek.com/v1}"
    ↓ 正则匹配
var_name = "LLM_BASE_URL", default = "https://api.deepseek.com/v1"
    ↓ os.environ.get("LLM_BASE_URL") → None(没设置)
    ↓ 用默认值
最终值: "https://api.deepseek.com/v1"

递归替换:处理嵌套结构

python 复制代码
def _resolve_dict_env_vars(d: dict) -> dict:
    """递归替换字典中所有字符串值的环境变量"""
    result = {}
    for k, v in d.items():
        if isinstance(v, str):
            result[k] = _resolve_env_vars(v)        # 字符串:替换
        elif isinstance(v, dict):
            result[k] = _resolve_dict_env_vars(v)   # 字典:递归
        elif isinstance(v, list):
            result[k] = [
                _resolve_env_vars(item) if isinstance(item, str) else item
                for item in v                        # 列表:逐个替换字符串
            ]
        else:
            result[k] = v                            # 其他类型:不动
    return result

三种情况递归处理

  • 字符串 → 调 _resolve_env_vars 替换
  • 字典 → 递归处理子字典
  • 列表 → 逐个元素检查,字符串的替换

这样嵌套配置里的环境变量也能替换

bash 复制代码
llm:
  api_key: "${DEEPSEEK_API_KEY}"       # 嵌套在 llm 下
  model: "glm-4-flash"

hotspot:
  platforms:
    - "${HOTSPOT_PLATFORM_1:xiaohongshu}"  # 列表里的也能替换
    - "douyin"

环境变量 vs 配置文件

信息类型 放哪里 为什么
API Key 环境变量 敏感,不入 Git
数据库密码 环境变量 敏感,不入 Git
模型名称 配置文件 不敏感,需要版本管理
温度参数 配置文件 不敏感,需要版本管理
赛道列表 options.yaml 不敏感,需要运行时修改

原则:敏感信息走环境变量,非敏感信息走配置文件 --- 环境变量不入 Git(.env 加到 .gitignore),配置文件入 Git(有版本历史)。

七、配置加载器:YAML → Pydantic

加载流程全貌

python 复制代码
# config/loader.py

def load_config(config_path: Optional[str | Path] = None) -> AppConfig:
    """从 YAML 文件加载应用配置"""
    if config_path is None:
        # 尝试默认路径
        default_path = Path("config/default.yaml")
        if default_path.exists():
            config_path = default_path
        else:
            logger.info("未找到配置文件,使用默认配置")
            return AppConfig()

    config_path = Path(config_path)
    if not config_path.exists():
        logger.warning(f"配置文件 {config_path} 不存在,使用默认配置")
        return AppConfig()

    logger.info(f"加载配置文件: {config_path}")
    with open(config_path, "r", encoding="utf-8") as f:
        raw_config = yaml.safe_load(f) or {}

    # 替换环境变量
    resolved_config = _resolve_dict_env_vars(raw_config)

    return AppConfig.model_validate(resolved_config)

完整的加载链

scss 复制代码
config/default.yaml(YAML 文件)
    ↓ open() + yaml.safe_load()
raw_config(原始字典,可能含 ${ENV_VAR})
    ↓ _resolve_dict_env_vars()
resolved_config(替换后的字典,环境变量已填充)
    ↓ AppConfig.model_validate()
AppConfig(Pydantic 实例,类型校验通过)
    ↓ 传入 PipelineRunner
config.llm, config.storage, config.quality...(各模块使用)

三道防线

步骤 作用 失败会怎样
yaml.safe_load() 解析 YAML 语法 YAML 语法错 → 报错
_resolve_dict_env_vars() 替换环境变量 环境变量没设 → 用默认值或保留原样
AppConfig.model_validate() Pydantic 类型校验 类型错/值域错 → 报错,启动失败

第三道是关键 --- model_validate 会校验所有字段的类型和值域。配置错了,启动时就失败,不会带到运行时。

优雅降级:配置文件不存在时的处理

css 复制代码
if config_path is None:
    default_path = Path("config/default.yaml")
    if default_path.exists():
        config_path = default_path
    else:
        logger.info("未找到配置文件,使用默认配置")
        return AppConfig()       # ← 返回全默认配置

config_path = Path(config_path)
if not config_path.exists():
    logger.warning(f"配置文件 {config_path} 不存在,使用默认配置")
    return AppConfig()           # ← 返回全默认配置

两种"找不到配置文件"的情况

  1. 没传路径,默认路径也不存在 → 返回 AppConfig()(全默认值)
  2. 传了路径,但文件不存在 → 返回 AppConfig()(全默认值)

不崩溃,用默认配置跑 --- 这对于快速试用很重要。用户 clone 项目后不写配置文件也能跑起来,全用默认值。

配置注入:从顶层到各模块

ini 复制代码
# pipeline/runner.py
class PipelineRunner:
    def __init__(self, config: AppConfig, repo: Repository, cache=None):
        self.config = config

        # config.llm → LLMClient
        self.llm = LLMClient(
            base_url=config.llm.base_url,
            api_key=config.llm.api_key,
            model=config.llm.model,
        )

        # config.quality → QualityOrchestrator
        self.quality_orchestrator = QualityOrchestrator(
            sensitive_filter=SensitiveFilter(
                sensitive_words_file=config.quality.sensitive_words_file,
            ),
        )

config 在最顶层加载,逐层注入到需要的地方 --- PipelineRunner 拿到 config 后,取需要的部分传给每个模块。

scss 复制代码
AppConfig
├── config.llm         → LLMClient(base_url, api_key, model)
├── config.quality     → SensitiveFilter(sensitive_words_file)
├── config.storage     → Repository(persist_dir, persist_format)
├── config.cache       → init_cache(max_size, default_ttl)
├── config.task        → TaskEngine(max_concurrent, default_timeout)
└── config.hotspot     → HotspotCrawler(platforms, top_k)

业务模块不知道配置文件长什么样 --- 只知道构造函数传进来的参数。换配置源(YAML → 环境变量 → 数据库)只改 load_config(),业务模块不用改。

踩坑记录

坑1:赛道硬编码在 JS 里,加"北漂"要改 3 个文件

ini 复制代码
早期前端代码:
const NICHES = ["beauty", "career", "parenting", "tech", "fitness", "food"];

想加"北漂"赛道 →
  ① 后端 persona/schema.py 的 Niche 枚举加 BEIPING = "北漂"
  ② 前端 JS 的 NICHES 数组加 "北漂"
  ③ config/personas/ 加一个北漂人设模板
  → 改了 3 个文件,其中 2 个是代码
  → 后端改完要重启,前端改完要重新部署
  → 而且经常忘了同步:后端加了前端没加,用户选不到

修复 :选项列表抽到 options.yaml,前端从 API 动态加载。

bash 复制代码
修复后,加"北漂"赛道 →
  ① POST /api/options/niches {"key": "北漂", "label": "北漂"}
  → options.yaml 自动写入
  ② 前端刷新页面,下拉框自动出现"北漂"
  → 不用改任何代码,不用重启

教训:凡是"可枚举的业务值"(赛道、格式、风格),都不要硬编码在代码里。抽成配置 + API 动态加载,加新值时零代码改动。

坑2:Niche 枚举和 options.yaml 不同步

vbnet 复制代码
persona/schema.py 里 Niche 枚举有 10 个值:
class Niche(str, Enum):
    BEAUTY = "beauty"
    CAREER = "career"
    PARENTING = "parenting"
    ...

但 options.yaml 里只有 6 个赛道:
niches:
- key: beauty
- key: career
- key: parenting
...

→ 枚举里有"travel",但 options.yaml 没有
→ 用户从前端创建人设时选不到"travel"
→ 但代码里 PersonaConfig(niche="travel") 又能通过
→ 前端和后端的"可选值"不一致

根因:枚举(代码)和选项列表(配置)是两套数据源,没有同步机制。

修复PersonaConfig.niche 的类型从 Niche 枚举改成 str,不再做枚举校验,改由前端从 options API 获取可选值。

ini 复制代码
# ❌ 之前:用枚举限制
niche: Niche = Field(default=Niche.BEAUTY)  # 只能选枚举里的值

# ✅ 之后:用 str,不限制
niche: str = Field(default="beauty")  # 任何字符串都行,由前端 options API 限制可选值

教训 :当配置和代码都有"可选值"定义时,以配置为准。代码里的枚举只做"代码内部引用"(比如 if niche == Niche.BEAUTY),不做"用户输入校验"。用户输入的可选值由配置(options.yaml)决定。

坑3:API Key 提交到 Git

vbnet 复制代码
早期 config/default.yaml:
llm:
  api_key: "sk-xxxxxxxxxxxxxxxx"  # 真实的 Key

git commit -m "init"
git push
→ API Key 永久留在 Git 历史里
→ 即使后来删了,git log 也能翻到
→ 只能去平台 revoke 重新生成

修复:API Key 改用环境变量引用。

bash 复制代码
# config/default.yaml
llm:
  api_key: "${DEEPSEEK_API_KEY}"   # 引用环境变量
ini 复制代码
# .env 文件(不入 Git)
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx

# .gitignore
.env

教训 :任何敏感信息(API Key、密码、Token)都不入配置文件,用 ${ENV_VAR} 引用环境变量。.env 文件加到 .gitignore,永远不提交。

坑4:配置改了但没重启

arduino 复制代码
修改 config/default.yaml:
  llm.temperature: 0.7 → 0.9

→ 页面上生成内容,发现温度还是 0.7
→ 因为 AppConfig 在启动时加载一次,之后不会重新读文件
→ 改了配置文件但没重启服务,用的还是旧配置

根因 :全局配置(default.yaml)在启动时一次性加载到内存,运行时不重新读取。

这是设计选择,不是 bug --- 全局配置是系统级参数,变更频率低,重启代价可接受。如果改成运行时热加载,需要处理"配置变更后已创建的对象怎么办"(LLMClient 已经用旧配置创建了,要重新创建吗?),复杂度大幅增加。

当前方案

  • 全局配置default.yaml)→ 改完要重启
  • 选项列表options.yaml)→ 通过 API 改,不用重启(每次 API 调用都重新读文件)
  • 人设模板personas/*.yaml)→ 通过 API 改,不用重启

教训:区分"启动时加载"和"运行时加载"。系统级配置启动时加载(改了要重启),业务级配置运行时加载(通过 API 改,不用重启)。不要把所有配置都做成热加载 --- 复杂度不值得。

关键 Takeaway

  1. 三层配置各司其职 --- 全局配置(default.yaml)管系统参数,人设模板(personas/*.yaml)管业务参数,选项列表(options.yaml)管可枚举值。系统级配置走文件 + 重启,业务级配置走 API + 热加载。
  2. 可枚举的业务值不要硬编码 --- 赛道、格式、风格这类"可枚举值"抽到 options.yaml + API 动态加载。加新赛道只需 POST 一个 API,前端自动出现,不用改任何代码。代码里的枚举只做内部引用,不做用户输入校验。
  3. 敏感信息走环境变量,配置文件只放非敏感信息 --- ${ENV_VAR} 语法在加载时自动替换,API Key 不入 Git,不入配置文件。_resolve_dict_env_vars 递归替换嵌套结构中的环境变量引用。

下篇预告

下一篇:《持久化与缓存:Agent的数据底座

本文讲了配置驱动 --- 同一套代码怎么通过不同配置服务 100 个赛道。但有个问题没讲:配置和数据存哪里? 重启后还在吗?热点数据每次都重新爬吗?

复制代码
没有持久化 → 重启就忘(金鱼记忆)
有持久化 → 重启后数据还在
有缓存 → 热点数据 10 分钟内不重复爬

下一篇讲持久化与缓存 --- 三层存储架构(MemoryStore → FilePersistence → Repository)、持久化时机、缓存策略(LRU + TTL)。

相关推荐
不好听6131 小时前
工具调用——让模型把答案"填进表格"而不是"写在作文里"
llm
不好听6132 小时前
StructuredOutputParser——别再靠"哄",把 schema 讲清楚再验收
llm
靠谱者也2 小时前
从“会聊天”到“能交付”:AI Agent 工程化落地的五个关键设计
agent
用户699390950252 小时前
一个开发者的私人 skills 文件夹,怎么干过了 Anthropic 官方库
agent
武子康2 小时前
从声学信号到工具阻断:实时语音安全决策门的系统设计
人工智能·llm·agent
Csvn2 小时前
第 18 章 学习与适应 Learning
人工智能·aigc·agent
tachibana23 小时前
Embedding 有哪几种算法?
人工智能·算法·ai·大模型·llm·embedding·agent
大鹏的NLP博客3 小时前
拆解 Agent Memory:从认知心理学映射到工业级工程落地
人工智能·agent·memory
FanetheDivine11 小时前
学习Agent开发9 OM 与前缀缓存
agent·ai编程