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

0 阅读15分钟

先说结论

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

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

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 个文件

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

硬编码的问题

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

配置驱动的目标

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

配置驱动的三个原则

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

二、三层配置体系

配置的三个层次

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

三层各管各的事

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

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

三、全局配置:default.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异步任务调并发数、改超时
webWeb 服务改端口、开关热重载
hotspot热点抓取开关平台、调缓存时间

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

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

# config/models.pyclass 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_validatortemperature 必须在 0.0~2.0 之间,max_tokens 必须为正整数。配置写错了,启动时就报错,不会等到运行时才发现。

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

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

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

# 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 文件里没写某个块,也有默认值兜底。

# 如果 default.yaml 只写了这些:
llm:
  model: "glm-4-flash"

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

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

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

两个人设模板的对比

# 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
# 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,不同的配置值,产出完全不同的内容

配置项美妆温柔职场犀利区别
nichebeautycareer赛道不同
persona_typegentle_expertsharp_reviewer人设类型不同
writing_stylecasualsharp文风不同
content_formatxiaohongshulong_article输出格式不同
opening_template"姐妹们~…"""(空)开头风格不同
emoji_frequencymediumlowemoji 用量不同
paragraph_max_chars150200段落长度不同
banned_words最便宜、世界第一绝对、一定禁忌词不同
temperature0.80.7生成多样性不同

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

人设的数据模型

# 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

# 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)

加载流程

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 加载人设

# 从预置模板加载
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

选项配置:可枚举的值域

# 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文案风格轻松口语、专业严谨

每个选项有三个字段

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

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

选项 API:运行时增删

# 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 端点

@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/optionsGET获取所有分类的所有选项
/api/options/{category}GET获取某个分类的选项列表
/api/options/{category}POST添加自定义选项
/api/options/{category}/{key}DELETE删除自定义选项

注意删除时的保护

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

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

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

❌ 硬编码时代(前端 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 加了"北漂"赛道,前端刷新页面就自动出现,不用改前端代码。

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

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

问题:API Key 写在 YAML 里

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

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

解决:环境变量引用

# 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},加载时自动替换成环境变量的值。

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

加载时的替换过程

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

有默认值的情况

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"

递归替换:处理嵌套结构

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 替换
  • 字典 → 递归处理子字典
  • 列表 → 逐个元素检查,字符串的替换

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

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

加载流程全貌

# 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)

完整的加载链

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 会校验所有字段的类型和值域。配置错了,启动时就失败,不会带到运行时。

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

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 项目后不写配置文件也能跑起来,全用默认值。

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

# 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 后,取需要的部分传给每个模块。

AppConfig
├── config.llmLLMClient(base_url, api_key, model)
├── config.qualitySensitiveFilter(sensitive_words_file)
├── config.storageRepository(persist_dir, persist_format)
├── config.cacheinit_cache(max_size, default_ttl)
├── config.taskTaskEngine(max_concurrent, default_timeout)
└── config.hotspotHotspotCrawler(platforms, top_k)

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

踩坑记录

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

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

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

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

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

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

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

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 获取可选值。

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

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

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

坑3:API Key 提交到 Git

早期 config/default.yaml:
llm:
  api_key: "sk-xxxxxxxxxxxxxxxx"  # 真实的 Key

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

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

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

# .gitignore
.env

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

坑4:配置改了但没重启

修改 config/default.yaml:
  llm.temperature: 0.70.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)。