先说结论
早期赛道、格式、风格全硬编码在代码里 --- 加一个"北漂"赛道要改 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 个是代码,要重新部署
→ 而且每个新人来都要问"赛道在哪里加?"
硬编码的问题:
- 加赛道要改代码 --- 改代码 = 走开发流程 = 提 PR = 审代码 = 部署 = 重启
- 前后端不同步 --- 后端加了"北漂"枚举,前端 JS 忘了加,用户创建人设时选不到
- 无法运行时扩展 --- 想加"考研"赛道,必须停机改代码重启
- 配置散落 --- 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 获取,下拉框自动出现"北漂"
→ 不用改任何代码,不用重启
配置驱动的三个原则:
- 代码不包含业务参数 --- 赛道、风格、温度、阈值全在配置文件
- 配置可运行时修改 --- 通过 API 增删选项,不用重启
- 敏感信息走环境变量 --- 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_profile 和 style_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
两种创建人设的方式:
- 从模板加载 ---
persona load <yaml>,读 YAML 文件,适合批量预设 - 交互式创建 ---
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() # ← 返回全默认配置
两种"找不到配置文件"的情况:
- 没传路径,默认路径也不存在 → 返回
AppConfig()(全默认值) - 传了路径,但文件不存在 → 返回
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
- 三层配置各司其职 --- 全局配置(
default.yaml)管系统参数,人设模板(personas/*.yaml)管业务参数,选项列表(options.yaml)管可枚举值。系统级配置走文件 + 重启,业务级配置走 API + 热加载。 - 可枚举的业务值不要硬编码 --- 赛道、格式、风格这类"可枚举值"抽到
options.yaml+ API 动态加载。加新赛道只需 POST 一个 API,前端自动出现,不用改任何代码。代码里的枚举只做内部引用,不做用户输入校验。 - 敏感信息走环境变量,配置文件只放非敏感信息 ---
${ENV_VAR}语法在加载时自动替换,API Key 不入 Git,不入配置文件。_resolve_dict_env_vars递归替换嵌套结构中的环境变量引用。
下篇预告
下一篇:《持久化与缓存:Agent的数据底座》
本文讲了配置驱动 --- 同一套代码怎么通过不同配置服务 100 个赛道。但有个问题没讲:配置和数据存哪里? 重启后还在吗?热点数据每次都重新爬吗?
没有持久化 → 重启就忘(金鱼记忆)
有持久化 → 重启后数据还在
有缓存 → 热点数据 10 分钟内不重复爬
下一篇讲持久化与缓存 --- 三层存储架构(MemoryStore → FilePersistence → Repository)、持久化时机、缓存策略(LRU + TTL)。