Ch02 · 提示词工程管理:从硬编码到可视化
覆盖提交:
aead072(002) ·3595a3b(010) ·1aa9262(013) ·c5e06c4(014) 难度:★★☆☆☆ 阶段:Day 1–5 关键词:Prompt 三层次 / 模板化 / 版本管理 / 幻觉第一类 / 文本资产
本章导读
上一章,你让模型「看见」了文档——num_ctx 从默认的 2048 拉到 8192,私域手册的前半本终于进了模型的视野。
但看得见,不等于会答。模型还得知道三件事:什么时候该答、按什么规矩答、以什么口吻答。这三件事,全靠提示词。
麻烦的是,提示词一旦散在代码里,改一次要改代码、要重启、要提测,还说不清线上生效的是哪一版。所以本章只干一件事:把提示词当成资产管起来——能存、能改、能回滚、能看版本。
这笔投入第 3 天花出去,第 4 天就兑现了:第 014 次提交「解决闲聊幻觉」,改了 113 行提示词,当天上线,从改到生效不到 10 秒。没有这套管理,同样的修法至少拖一天。
还记得 ch01 第三部分里那些各司其职的节点吗——
classify判意图、grade打分、rewrite改写、generate写答案,每个节点压着不同的温度。每个节点背后,都压着一份提示词。 节点越多,提示词越多,越需要管理。这就是本章的舞台。
本集学习目标
学完这一章,回到这张表逐一自查——四条,一条都不能少:
| # | 目标 | 达标标准 |
|---|---|---|
| 1 | 搞懂「提示词工程」到底管什么 | 分得清 L1 写得好 / L2 管得住 / L3 测得出,能说出为什么 L2 是 L3 的前提 |
| 2 | 把提示词从代码搬进数据库 | 会设计表结构(版本号是灵魂)、会写带版本号的缓存、会用白名单渲染 |
| 3 | 搞懂第一类幻觉的根因 | 知道「不该检索时检索」的根在路由不在措辞,会做 chitchat 分流 |
| 4 | 学会管另一类文本资产:注释 | 会按「意外程度」判断注释量,只写「为什么」不写「怎么做」 |
目标 1 是认知地基——先分清「写好提示词」和「管好提示词」是两回事;目标 2 是本章的工程主体,也是「第二天就兑现价值」的地方;目标 3 是全章最反直觉、最容易调错方向的一个点;目标 4 是顺手捡的便宜,ROI 极高。带着这四个目标往下读,读完回来打钩。
📐 理论基石:提示词 = 给条件分布加约束,工程化 = 把约束固化进版本
为什么「提示词工程」要单独成章、还要进数据库?(呼应 P0-02 §1/§2):
- 提示的本质是给模型的条件分布加约束:
P(回答 | 提示)。prompt_manager.py:16-17的prompt_templates表,就是把「这组约束」固化成可存储、可版本化的资产,而不是散落在代码里的字符串。 - 角色设定(如
prompt_manager.py:82-93的 classify 模板写system提示)是风格先验,不是知识注入——它约束「以什么口吻答」,不改变模型会不会答错事实(呼应 P0-02 §3)。所以「调角色」救不了事实性幻觉。 - few-shot / CoT(P0-02 §2)是更重的约束:把示例或推理步骤摊进上下文窗口,让分布更集中。但这些约束一旦要 A/B,就必须能回滚到上一个版本——没有版本号(本章 L2),你连「哪次提示改动让闲聊变差」都追不回。
- 采样温度是分布宽度的旋钮:
llm_gateway.py:109默认temperature: float = 0.0,548行的opts{temperature,seed}是调用入口。低温 = 收敛、可复现,正是「工程化提示词」想要的可控性。很多人以为幻觉是「措辞没调好」,其实temperature是更直接的开关。
本章关键洞察的理论版本:闲聊也幻觉,根因往往不在提示词措辞,而在路由(让模型在「不该检索时检索了」)。提示只能约束分布,改不了检索链路——这正是 P0-02「提示有边界」的结论。所以 ch02 把提示搬进数据库,价值不在「改措辞更方便」,而在「让路由/提示的每次改动都可版本化、可 A/B、可回滚」。
先记住这条主线,现在开始动手——第一件事,搞清楚「管起来」到底管什么。
第一部分 · 为什么要把提示词管起来
这一部分拿下目标 1。 很多人以为「提示词工程」就是钻研措辞——怎么写让模型更听话。方向一开始就偏了。本章要教你的第一件事是:「写好提示词」和「管好提示词」是两回事,前者是手艺,后者是工程。
1.1 三个层次:L1 写得好 → L2 管得住 → L3 测得出
图 1 提示词工程的三个层次。L2「管得住」是 L3「测得出」的前提——提示词不进数据库、不带版本号,你连 A/B 都没法做。
| 层次 | 内容 | 本项目时点 |
|---|---|---|
| L1 写得好 | 措辞、few-shot、思维链 | Day 1 起 |
| L2 管得住 | 版本、灰度、回滚、可视化 | Day 3(013) |
| L3 测得出 | 离线评测集、A/B 测试 | Day 15(Ch18) |
大多数团队卡在 L1 —— 反复调措辞,但无法证明"变好了"。因为L2 是 L3 的前提:没有版本号,A/B 测试就要部署两套代码,成本高到不可行。
1.2 一个认知:管好提示词,是工程能力,不是玄学
「写好提示词」确实在贬值——新一代模型内置推理,很多「咒语」没用了(详见本章思考题 7)。但「管好提示词」在升值,原因就一条:
模型越强,应用场景越多,提示词数量暴增,改坏的成本越高。 本项目到后期有 13 个节点的模板要维护——不靠版本号、不靠可视化后台,谁记得住 13 份提示词各自改到第几版?
第一部分小结 · 对照自查
- ✓ 三个层次——L1 写得好(决定单次上限)、L2 管得住(决定能否持续优化)、L3 测得出(决定方向对不对)
- ✓ 关键结论——L2 是 L3 的前提:没有版本号,A/B 测试就是纸上谈兵
- ✓ 认知刷新——「写好提示词」在贬值,「管好提示词」在升值
到这里,目标 1 完成。但「管得住」三个字,第一步是让提示词存得进数据库。这就是第二部分。
第二部分 · 把提示词搬进数据库
这一部分拿下目标 2,也是本章的工程主体。 路线是:先看改造前有多痛,再看表结构怎么设计、缓存怎么失效、渲染怎么做对,最后对照改造前后的账本。
2.1 改造前:散落在业务代码里的提示词
# ❌ 散落在业务代码里
prompt = f"""你是一个助手。请根据以下上下文回答:
{context}
问题:{query}
"""
三个致命问题:
| 问题 | 后果 |
|---|---|
| 改提示词要改代码 + 重启 | 改一句话要提测、部署,10 分钟起 |
| 只有研发能调优 | 产品/运营想优化,必须排队等研发 |
| 不知道线上生效的是哪一版 | 出问题时无法定位,也无法回滚 |
2.2 表结构设计:版本号是灵魂
-- config/init_db.sql:20
CREATE TABLE IF NOT EXISTS `prompt_templates` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(128) NOT NULL COMMENT '模板唯一标识',
`title` VARCHAR(255) COMMENT '中文名,后台展示用',
`content` TEXT NOT NULL COMMENT '模板内容,支持 {变量} 占位符',
`category` VARCHAR(64) COMMENT '分类:classify/grade/rewrite/generate...',
`version` INT DEFAULT 1,
`enabled` TINYINT(1) DEFAULT 1,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
四个关键字段:
| 字段 | 作用 | 为什么必要 |
|---|---|---|
name | 唯一标识 | 代码里按 name 取模板 |
version | 版本号 | 可回滚、可 A/B |
enabled | 开关 | 快速下线有问题的提示词 |
category | 分类 | 按节点分类管理 |
2.3 三层结构
┌─────────────────┐
│ 后台管理页面 │ 编辑 / 灰度 / 回滚 ← rag_web_server.py (+924 行)
└────────┬────────┘
↓
┌─────────────────┐
│ prompt_templates│ MySQL 持久化 + 版本号
└────────┬────────┘
↓
┌─────────────────┐
│ prompt_manager │ 加载 / 渲染 / 缓存失效 ← prompt_manager.py (684 行)
└─────────────────┘
2.4 缓存与热更新
def get(self, name: str) -> str:
"""缓存 key 必须包含版本号 —— 模板一改,key 变,自动失效。"""
version = self._current_version(name)
key = f"prompt:{name}:v{version}"
if key in self._cache:
return self._cache[key]
content = self._load_from_db(name)
self._cache[key] = content
return content
改提示词不用重启服务。 这在调参阶段价值巨大。
2.5 渲染:为什么不用 str.format()
def render(self, name: str, **kwargs) -> str:
"""渲染模板。
注意:不用 str.format(),因为文档内容里可能含花括号
(JSON 片段、代码块),format 会抛 KeyError。
改用白名单替换:只替换模板声明过的变量。
"""
content = self.get(name)
for key, value in kwargs.items():
content = content.replace("{" + key + "}", str(value))
return content
为什么 str.format() 会出问题?
# 情况 1:文档内容含花括号 → 抛异常
context = '配置:{"timeout": 30}'
"上下文:{context}".format(context=context) # 若还有其他占位符会 KeyError
# 情况 2:更危险 —— 内容里的占位符被意外替换
context = '用户问:{query} 是什么意思'
# 文档内容里的 {query} 可能被替换,造成内容错乱
2.6 兜底与校验:别让一次手滑打翻全站
兜底——渲染后检查是否还有未替换的占位符:
import re
def render(template, **kwargs):
result = template
for key, value in kwargs.items():
result = result.replace("{" + key + "}", str(value))
# 渲染后检查是否还有未替换的占位符
leftover = set(re.findall(r"\{(\w+)\}", result))
if leftover:
logger.warning(f"模板存在未替换变量: {leftover}")
# 生产环境降级(填空),开发环境应报错
return result
校验——防止后台改错变量名:
ALLOWED_VARS = {"query", "context", "history", "role", "tenant"}
def validate_template(content: str) -> tuple[bool, str]:
"""校验模板中的占位符是否合法(后台保存前调用)。"""
found = set(re.findall(r"\{(\w+)\}", content))
invalid = found - ALLOWED_VARS
if invalid:
return False, f"未知变量: {invalid},可用: {ALLOWED_VARS}"
return True, "OK"
2.7 改造前后的账本
| 维度 | 硬编码 | 后台管理 |
|---|---|---|
| 改一次耗时 | ~10 分钟 | ~10 秒 |
| 谁能改 | 仅研发 | 产品/运营也可 |
| 版本管理 | 靠 git | 数据库版本号 |
| 回滚 | 需重新部署 | 一键回滚 |
| A/B 测试 | 不可能 | 可行 |
第二部分小结 · 对照自查
- ✓ 表结构——
name(唯一)+version(可回滚可 A/B)+enabled(开关)+category(分类),四个字段缺一不可 - ✓ 缓存失效——key 带版本号
prompt:{name}:v{version},一改自动失效,不用重启 - ✓ 渲染——白名单替换,不用
str.format()(内容里的花括号会炸) - ✓ 兜底 + 校验——渲染后查漏、保存前查错,两道防线挡手滑
到这里,目标 2 完成——提示词已经「存得进、改得动、回得了」。但搬进数据库的价值,不只在「改起来方便」,而在它让你第二天就修掉了一个顽固的 bug。这个 bug 就是第三部分的主角。
第三部分 · 第一类幻觉:不该检索时检索
这一部分拿下目标 3,也是全章最反直觉的一个点。 一句话剧透:这个幻觉,调提示词没用。
3.1 现象:闲聊也编出「知识库腔」
用户问"你好"、"你是谁",系统也去检索知识库,然后编造一段"知识库风格的回答":
用户:你好
系统:根据文档第 2.1 节,您好!本设备支持多种通讯协议... ← 荒谬
3.2 根因:不是提示词的问题,是路由的问题
这是本章最关键的一点。
错误的诊断:"提示词不够强,要加更严格的约束。"
正确的诊断:系统在该走"直接回答"的时候走了"检索回答"。
用户问"你好" → classify 判为 simple → 走检索 → 编造基于文档的回答
↑ 根因在这里
如果只在提示词里加约束("如果是闲聊就直接回答"),效果有限,因为:
- 模型已经拿到了一堆文档,它会倾向于"用上"这些文档
- 约束越强,模型越容易在两个指令间摇摆
正确的修法:在意图识别阶段就分流,闲聊根本不进检索分支。
对照 ch01:上一章你排掉的是第 2 类幻觉(检索到了、但 num_ctx 截断没读进去),这一章排的是第 1 类(压根不该去检索)。两类一个在「看得见」、一个在「该不该看」,都是工程层的错,不是模型的锅。
3.3 修法:chitchat 分支
# langgraph_rag_agent.py:882 —— 三条分支
graph.add_conditional_edges(
"classify",
self.route_after_classify,
{
"simple": "query_rewrite", # 简单查询 → 多轮检索
"complex": "planner", # 复杂查询 → 多智能体拆解
"chitchat": "direct_llm", # 闲聊 → 直接回答(不检索!)
},
)
# langgraph_rag_agent.py:925
graph.add_edge("direct_llm", "respond") # 闲聊直答后直接出口
direct_llm 节点完全没有检索环节,从结构上杜绝了"闲聊编造文档内容"。
3.4 意图识别的实现(三代技术的第一代)
第 014 次提交时用的是快速分类(词表):
# langgraph_rag_agent.py:2438
def _quick_classify(self, query: str) -> str:
"""快速分类:基于词表的规则判定。
这是三代意图识别技术里的第一代(详见 Ch11):
第一代 关键词/词表 ← 本章
第二代 LLM 分类
第三代 向量语义路由 ← 最终形态
"""
# langgraph_rag_agent.py:979
def node_classify(self, state: AgentState) -> dict:
"""意图识别节点 —— 系统的分叉口。"""
# langgraph_rag_agent.py:1158
def route_after_classify(self, state: AgentState) -> str:
"""根据意图返回下一个节点名(必须命中 mapping 的键)。"""
这次提交的 119 行改动里,113 行在
prompt_manager.py—— 说明"提示词管理"这次改造直接加速了幻觉修复。这正是 L2 能力的价值兑现:没有版本号后台,这 113 行提示词还得走「改代码 + 重启」的老路。
3.5 幻觉的四种类型:先分类,再下药
| 类型 | 表现 | 修法 | 章节 |
|---|---|---|---|
| ① 不该检索时检索 | 闲聊/OOS 也去查文档 | 路由分流 | 本章 |
| ② 检索到了但没读进去 | 文档在 context 里但被截断 | num_ctx / 出口契约 | Ch01、Ch12 |
| ③ 检索到了但理解错 | 模型误读数字/单位 | prompt 约束 + 强模型 | Ch20 |
| ④ 检索不到就编 | 查空也硬合成答案 | 验收门四态判定 | Ch23 |
很多人一遇到幻觉就调提示词,但实际上只有第 ③ 类适合用提示词解决。 先分类,再下药。
第三部分小结 · 对照自查
- ✓ 现象——闲聊也编「知识库腔」的回答
- ✓ 根因——在路由,不在提示词:该直答时走了检索
- ✓ 修法——
{"chitchat": "direct_llm"},从结构上分流,不从措辞上约束 - ✓ 认知——四类幻觉里,只有 1/4 适合用提示词解决
到这里,目标 3 完成——模型知道「什么时候不该去查」了。但本章还有一笔顺手的账要收:提示词是文本资产,注释也是。这就到了第四部分。
第四部分 · 另一类文本资产:注释
这一部分拿下目标 4。 提示词需要被管理,注释也一样——它们都是「写出来给人和机器看」的文本资产。区别只在于:提示词写给模型看,注释写给人看。
4.1 四个等级:什么样的注释才有价值
第 002 次提交只改了 13 行,但确立了注释的标准:
| 等级 | 内容 | 示例 |
|---|---|---|
| L0 有害 | 复述代码 | i += 1 # i 加 1 |
| L1 无用 | 重复函数名 | # 创建连接 / def create_conn() |
| L2 有用 | 解释为什么 | # 必须限 1,否则 Windows 段错误 |
| L3 珍贵 | 记录决策与替代方案 | # 曾尝试 X,失败因 Y,故改用 Z |
4.2 一个模板:六要素注释
第 010 次提交给 958 行 LangGraph 代码补了 1295 行注释(注释/代码比 135%)。以 MMR 算法为例,最终形成的注释模板:
# langgraph_rag_agent.py:2590
def _mmr_rerank(self, query: str, results: List, k: int = 5) -> List:
"""
【辅助:MMR (Maximal Marginal Relevance) 重排序】
作用:在相关性和多样性之间做平衡的文档排序算法。 ← ① 一句话职责
原理: ← ② 为什么需要
向量检索容易返回内容相似的多个文档(来自同一段落的不同切片)。
如果全部塞给 LLM,既浪费 token 空间,又让答案偏向重复内容。
MMR 同时优化两个目标: ← ③ 核心机制
- 相关性(Relevance):选与查询最相关的文档
- 多样性(Diversity):选与已选中文档不重复的新文档
算法(贪心): ← ④ 实现步骤
1. 按距离排序,选最相关的作为第一个选中项
2. 循环选择剩余项:
a. 计算它和已选中集合的最大相似度(Jaccard)
b. MMR 分数 = λ × 相关性 - (1-λ) × 冗余度
c. 选 MMR 分数最高的加入选中集合
λ(lambda_param=0.7)的含义: ← ⑤ 参数含义
- 越接近 1 越看重相关性,越接近 0 越看重多样性
- 0.7 是经验值,7:3 的权衡
参数:query / results / k ← ⑥ 签名说明
返回:MMR 排序后的文档列表
"""
六要素:职责 → 动机 → 机制 → 步骤 → 参数 → 返回值。
4.3 判断准则:意外程度决定注释量
| 代码类型 | 建议密度 | 本项目实例 |
|---|---|---|
| 通用工具函数 | 20-30% | safe_eval(说明安全设计) |
| 算法核心 | 50-100% | _mmr_rerank(135%) |
| 业务流程节点 | 30-50% | 13 个 LangGraph 节点 |
| 配置 | 50%+ | yaml 配置文件 |
| 简单 CRUD | 5-10% | 各业务模块 |
判据:代码的"意外程度"越高,注释越要多。
| 意外程度 | 例子 | 注释量 |
|---|---|---|
| 不意外 | get_user_by_id() | 0 行 |
| 轻度意外 | dist = -raw_score | 1-3 行 |
| 中度意外 | MMR 算法 | 10-20 行 |
| 高度意外 | Milvus COSINE 距离失真 | 20+ 行 |
记忆口诀:函数名能说清的,别写;函数名为止的,必须写。 注释只写「为什么」,不写「怎么做」——「怎么做」会变,「为什么」很少变。
4.4 这笔投资的回报
| 时点 | 场景 | 注释的价值 |
|---|---|---|
| 第 011 次提交 | 加记忆系统,要改节点 | 不用重读实现,看注释就知道在哪插 |
| 第 028 次提交 | 修记忆 bug | 注释写了"身份贯通"的设计前提 |
| 第 055 次提交 | 修 Bad Case #9 | 出口契约注释直接指出问题 |
| 第 069 次提交 | 改语义路由 | 路由节点注释说明三层降级设计 |
| 现在 | 写这份教程 | 80% 内容来自这些注释 |
半天投入,回报持续整个项目周期。 这是本项目 ROI 最高的一次"非功能"提交。
第四部分小结 · 对照自查
- ✓ 四个等级——L0 有害 / L1 无用 / L2 有用 / L3 珍贵,只写 L2/L3
- ✓ 六要素——职责、动机、机制、步骤、参数、返回值
- ✓ 判断准则——意外程度决定注释量,算法核心可以到 100%+
踩坑总表
到这里,四个目标全部拿下。把这一章的坑摆到一张表上——每一个都是真实踩过的:
| # | 坑 | 现象 | 根因 | 修法 |
|---|---|---|---|---|
| 1 | 占位符与内容花括号冲突 | 文档含 {...} 时 str.format() 抛 KeyError | 内容里的花括号被当成占位符 | 白名单逐个替换(见 2.5) |
| 2 | 缓存失效不及时 | 后台改了模板,服务还用旧的 | 缓存 key 没带版本号 | key = f"prompt:{name}:v{version}" |
| 3 | 多实例缓存不一致 | 4 个 worker,改了模板有的生效有的没生效 | 各进程独立内存缓存 | Redis 主动失效 / 缩短 TTL / 版本号轮询 |
| 4 | 后台改错变量名 | {query} 写成 {quer},全站问答报错 | 无校验 | 四道防线(校验 / 兜底 / 恢复默认 / 灰度) |
| 5 | 注释腐烂 | 改了代码没改注释,注释描述三个月前的逻辑 | 注释写了「怎么做」 | 只写「为什么」+ 关键断言写成可执行代码 |
这张表值得截图存好。愿意把这些坑一个个提前填平——这就是工程化 AI 和玩具 demo 的分水岭。
学习目标回顾
回到开头的四个目标,逐一自查:
- ✓ 目标 1 · 管什么——L1 写得好 / L2 管得住 / L3 测得出,L2 是 L3 的前提,你分清了
- ✓ 目标 2 · 搬进数据库——表结构带版本号、缓存 key 带版本号、白名单渲染,你会设计了
- ✓ 目标 3 · 第一类幻觉——根在路由不在措辞,chitchat 分流,你拿下了
- ✓ 目标 4 · 注释——意外程度原则、六要素模板,你立住了
四个全过,这一章就没白读。最后送一句话带走:
「写好提示词」是手艺,「管好提示词」是工程。 手艺会过时,工程不会——提示词越多、部署越大、改坏越贵,管理越值钱。
而管好提示词的第一步,永远是先承认:提示词只约束「怎么答」,不决定「答不答」——该不该答,是路由的事。
知识点卡片
【知识点】提示词工程的三个层次
L3 测得出 ← 离线评测集 / A/B(证明"变好了") ↑ 依赖 L2 管得住 ← 版本 / 灰度 / 回滚 / 可视化 ↑ 依赖 L1 写得好 ← 措辞 / few-shot / 思维链为什么 L2 是 L3 的前提? A/B 测试要求两组用不同版本提示词。硬编码的话要部署两个实例才能对比——成本极高。进数据库后,A/B 只是"按流量比例取不同 version"。
【知识点】幻觉的第一类:不该检索时检索
特征:用户问闲聊/OOS 问题,系统也去检索,然后编造"知识库风格"的回答。
为什么调提示词没用? 因为模型已经拿到了文档,它会倾向于"用上"这些文档。你加约束说"闲聊就直接回答",模型反而要在两个指令间摇摆。
正确修法:在路由层分流 —— 闲聊根本不进检索分支:
{"chitchat": "direct_llm"} # direct_llm 节点没有检索环节从结构上杜绝,而不是从措辞上约束。
【知识点】注释的"意外程度"原则
代码越意外,注释越要多。
判断方法:想象一个和你水平相当的同事读这段代码,他会在哪里卡住?在每个卡住的地方写注释。
反例(浪费):
def get_user_by_id(user_id): """根据 ID 获取用户。""" # 函数名已经说清楚了正例(必要):
# BM25 取负:系统内统一约定"距离越小越相似", # 而 BM25 原始分数是"越大越相似"。漏掉负号会导致排序完全反向。 dist = -raw_score记忆口诀:函数名能说清的,别写;函数名为止的,必须写。
练习题
基础题
1. 用户问"你好",系统回答"根据文档第 2.1 节,您好!本设备支持多种通讯协议"。有人说这是提示词不够强。请评价这个诊断。
答案
这个诊断是错的。根因不在提示词,在路由。
正确诊断:系统在该走"直接回答"的时候走了"检索回答"。
用户问"你好" → classify 判为 simple → 走检索 → 编造基于文档的回答
↑ 根因在这里
为什么调提示词效果有限?
因为模型已经拿到了一堆文档。此时你加约束说"如果是闲聊就直接回答",模型面临两个冲突的指令:
- 指令 A(隐含):这里有文档,你应该基于它回答
- 指令 B(显式):闲聊就直接回答
模型会在两者间摇摆,表现不稳定。
正确修法:在意图识别阶段分流,闲聊根本不进检索分支:
graph.add_conditional_edges("classify", route_after_classify, {
"chitchat": "direct_llm", # direct_llm 节点没有检索环节
})
从结构上杜绝,而不是从措辞上约束。
通用原则:当一个问题反复通过"加约束"解决不了时,问自己——是不是在错误的层次上解决问题?
提示词是"生成层"的手段,而这个问题出在"路由层"。
2. 为什么渲染提示词模板时不能用 str.format()?
答案
两个原因:
原因 1:文档内容含花括号 → 抛异常
context = '配置示例:{"timeout": 30, "retry": 3}'
prompt = "上下文:{context}"
# format 会把 {"timeout"...} 当成占位符
prompt.format(context=context) # KeyError: 'timeout'
原因 2:更危险 —— 内容里的占位符被意外替换
context = '用户问:{query} 是什么意思' # 文档内容恰好含 {query}
prompt = "上下文:{context}\n问题:{query}"
result = prompt.format(context=context, query="心跳间隔")
# 某些渲染顺序下,context 里的 {query} 可能被替换 → 内容错乱
正确解法:白名单逐个替换
def render(template, **kwargs):
result = template
for key, value in kwargs.items():
result = result.replace("{" + key + "}", str(value))
return result
只替换模板声明过的变量,内容里的其他花括号原样保留。
这个坑的本质:把"不可控的输入"(文档内容)和"可控的模板"混在一起处理时,必须明确边界。
3. 提示词工程的 L1/L2/L3 分别是什么?为什么 L2 是 L3 的前提?
答案
| 层次 | 内容 |
|---|---|
| L1 写得好 | 措辞、few-shot、思维链 —— 决定单次效果上限 |
| L2 管得住 | 版本、灰度、回滚、可视化 —— 决定能否持续优化 |
| L3 测得出 | 离线评测集、A/B 测试 —— 决定优化方向对不对 |
为什么 L2 是 L3 的前提?
A/B 测试的技术要求:两组流量必须使用不同版本的提示词,且能分别统计效果。
- 硬编码场景:要实现 A/B,必须部署两个不同代码版本的实例,还要做流量分发 → 成本极高,几乎不可行
- 配置化场景:提示词带版本号存在数据库里,A/B 就是"按用户 ID 哈希,50% 取 v1,50% 取 v2" → 几行代码的事
同理,L3 的"离线评测"也依赖 L2:评测要批量跑不同版本的提示词,硬编码的话每测一个版本就要重新部署。
一句话:L1 决定上限,L2 决定能不能持续逼近上限,L3 决定你是不是在朝正确方向走。
进阶题
4. 服务部署了 4 个 worker,后台改了提示词后有的实例生效有的没生效。给出三种解决方案及取舍。
参考答案
原因:每个 worker 有独立的内存缓存,后台改的是数据库,各进程缓存不知道。
方案 1:缩短缓存 TTL(最简单)
CACHE_TTL = 10
def get(name):
if name in cache and time.time() - cache[name]["ts"] < CACHE_TTL:
return cache[name]["content"]
# 重新加载
- 优点:几行代码
- 缺点:最多 10 秒不一致窗口
方案 2:Redis 集中缓存 + 主动失效(推荐)
def get(name):
content = redis.get(f"prompt:{name}")
if content is None:
content = load_from_db(name)
redis.set(f"prompt:{name}", content)
return content
def update_template(name, content):
db.update(...)
redis.delete(f"prompt:{name}") # ← 关键:主动失效
- 优点:改完立即全局生效
- 缺点:多一个 Redis 依赖(可用本地二级缓存缓解)
方案 3:版本号轮询(无 Redis 时)
def get(name):
version = db.query_one("SELECT version FROM prompt_templates WHERE name=?", name)
if cache.get(name, {}).get("version") != version:
cache[name] = {"version": version, "content": load_content(name)}
return cache[name]["content"]
- 优点:不依赖 Redis
- 缺点:每次多一次轻量查询
推荐组合:本地缓存(毫秒级)+ Redis 版本号(秒级)+ 后台修改时主动 DEL。
本项目当时是单实例,没遇到这个问题。 但第 033 次提交引入 gunicorn 多 worker 后,这个设计是必须的。
5. 后台改提示词时打错变量名,导致线上所有问答报错。设计四道防线。
参考答案
防线 1:编辑时校验(前端 + 后端)
ALLOWED_VARS = {"query", "context", "history", "role", "tenant"}
def validate_template(content: str) -> tuple[bool, str]:
found = set(re.findall(r"\{(\w+)\}", content))
invalid = found - ALLOWED_VARS
if invalid:
return False, f"未知变量: {invalid},可用: {ALLOWED_VARS}"
return True, "OK"
前端:输入时下拉补全可用变量 后端:保存前校验,不合法直接拒绝
防线 2:渲染时兜底(不崩)
leftover = set(re.findall(r"\{(\w+)\}", result))
if leftover:
logger.warning(f"模板存在未替换变量: {leftover}")
for var in leftover:
result = result.replace("{" + var + "}", "")
关键决策:未替换变量是"填空"还是"报错"?
- 开发环境:严格报错(问题立刻暴露)
- 生产环境:降级填空 + 告警(服务可用)
防线 3:一键恢复默认
def reset_to_default(name):
default = BUILTIN_TEMPLATES[name] # 默认模板硬编码在代码里
db.update(name, content=default, version=version+1)
cache.invalidate(name)
默认模板硬编码在代码里作为兜底,数据库里的只是"当前生效版本"。
防线 4:灰度发布
新模板 → 10% 流量 → 观察 5 分钟错误率 → 正常则放大到 100% → 异常自动回滚
需要配合 Ch18 的评测体系(能快速判断新版更好还是更差)。
最小可用组合:防线 1(校验)+ 防线 3(恢复默认),能挡住 90% 事故,成本很低。
本项目实现了防线 1(部分)、2、3,未实现 4。
思考题
6. 本项目给 958 行代码写了 1295 行注释(注释/代码比 135%)。这是过度注释吗?请给出判断依据。
参考答案
不是过度注释。三条判断依据:
依据 1:看代码类型
langgraph_rag_agent.py 是编排 + 算法混合的文件:
- 13 个节点的业务逻辑(需说明职责与输入输出)
- MMR、RRF、query rewrite 等算法(需说明原理与参数)
- 3 个路由函数的分支策略(需说明兜底理由)
这类代码的"意外程度"普遍偏高,高注释密度合理。
对照:如果是 CRUD 密集的 sales_system.py,135% 就明显过度了。
依据 2:看内容而非数量
1295 行注释的分布:
L0 复述代码 ~0% ← 没有浪费
L1 重复函数名 ~10% ← 少量冗余
L2 解释为什么 ~60% ← 有价值
L3 决策与陷阱 ~30% ← 高价值
90% 有实质信息量。
依据 3:看后续回报(事后验证最有力)
这 1295 行注释在后续 33 天里:
- 支撑了第 011 次提交(加记忆)的快速改动
- 支撑了第 028 次提交(修 bug)的精确定位
- 支撑了第 055 次提交(Bad Case #9)的根因分析
- 支撑了这份教程的写作(80% 内容直接来自注释)
如果当时省下这半天,后面要多花几十倍时间重读代码。
但确有可改进之处:
少量注释属于 L1(如 """保存历史节点。"""),这些是浪费。理想做法是简单节点只写一句话,把篇幅留给复杂算法。
更好的做法:把关键断言从注释升级为可执行代码:
# 与其写注释
# 注意:distance 应在 [0,2] 区间
# 不如让代码自己校验
assert 0 <= best_distance <= 2, f"COSINE 距离越界: {best_distance}"
后者不会腐烂——因为腐烂时测试会失败。
结论:注释不是按行数衡量,而是按"是否提供了代码之外的信息"衡量。本次 1295 行里绝大部分提供了代码无法表达的信息(为什么、陷阱、决策、经验值),所以是必要的投资而非冗余。
7. 有人说「提示词工程已经过时了,现在模型足够聪明,不需要精心写提示词」。请评价。
参考答案
这个观点混淆了「提示词技巧」和「提示词管理」,前者确实在贬值,后者不会。
正在贬值的部分(L1 的一些技巧)
| 旧技巧 | 现状 |
|---|---|
| "Let's think step by step" 咒语 | 新一代模型内置推理,加不加差别不大 |
| 复杂的角色扮演设定 | 效果递减 |
| 手工构造 few-shot 示例 | 模型零样本能力已很强 |
| 各种"提示词咒语" | 大多是玄学 |
这些确实在过时,因为模型能力提升了,对措辞的敏感度下降。
不会过时的部分
1. 结构化输出要求(永远不会过时)
请以 JSON 格式输出:{"intent": "...", "confidence": 0.0-1.0}
模型再聪明,你不说清楚格式,它也可能返回散文。
2. 边界与约束(永远不会过时)
只依据提供的文档回答。文档未提及的,明确说"文档未提及",不要推测。
这是业务要求,不是模型能力问题。
3. 提示词管理(L2/L3)(越来越重要)
- 版本管理:模型再强,你还是要迭代提示词
- A/B 测试:还是要证明"改了更好"
- 灰度回滚:还是要防止改坏
随着提示词数量增长(本项目有 13 个节点的模板),管理的重要性不降反升。
一个反直觉的观察
模型越强,提示词管理越重要 —— 因为:
- 应用场景变多 → 提示词数量暴增
- 部署规模变大 → 改坏的成本变高
- 团队协作 → 需要版本和权限管理
本项目的启示
第 013 次提交花 1724 行做提示词管理,第二天就靠它修了幻觉(014)。
如果当时相信"提示词工程过时了"而不做这套管理:
- 修幻觉要改代码 + 重启 → 至少拖一天
- 后面 13 个节点的模板无法统一管理
- Ch18 的评测体系无法落地
一句话: "写好提示词"这个手艺在贬值,"管好提示词"这个工程能力在升值。
就像编程语言从汇编进化到高级语言——写代码的技巧简化了,但软件工程(版本、测试、部署)反而更复杂更重要了。
本章小结
| 收获 | 内容 |
|---|---|
| 一个层次 | L1 写得好 → L2 管得住 → L3 测得出(L2 是 L3 的前提) |
| 一个诊断 | 闲聊幻觉的根因在路由不在提示词 —— 从结构上分流,不从措辞上约束 |
| 一个技巧 | 渲染用白名单替换,不用 str.format() |
| 一个原则 | 注释的"意外程度"决定注释量;只写"为什么"可降低腐烂率 |
| 一个认知 | 只有 1/4 的幻觉适合用提示词解决 |
下一章:Ch03 · 工程卫生 —— 提示词管住了,但它跑在什么环境上?密钥怎么不泄露、依赖怎么选、文档怎么留痕(覆盖提交 003、004、005、009、017、018)。
导航:返回总目录 · 上一篇:大模型接入 · 下一篇:工程卫生 →