从零到一搭建企业级智能问答系统:Ch02 ·提示词工程管理

3 阅读29分钟

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-17prompt_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.0548 行的 opts{temperature,seed} 是调用入口。低温 = 收敛、可复现,正是「工程化提示词」想要的可控性。很多人以为幻觉是「措辞没调好」,其实 temperature 是更直接的开关。

本章关键洞察的理论版本:闲聊也幻觉,根因往往不在提示词措辞,而在路由(让模型在「不该检索时检索了」)。提示只能约束分布,改不了检索链路——这正是 P0-02「提示有边界」的结论。所以 ch02 把提示搬进数据库,价值不在「改措辞更方便」,而在「让路由/提示的每次改动都可版本化、可 A/B、可回滚」。

先记住这条主线,现在开始动手——第一件事,搞清楚「管起来」到底管什么。


第一部分 · 为什么要把提示词管起来

这一部分拿下目标 1。 很多人以为「提示词工程」就是钻研措辞——怎么写让模型更听话。方向一开始就偏了。本章要教你的第一件事是:「写好提示词」和「管好提示词」是两回事,前者是手艺,后者是工程。

1.1 三个层次:L1 写得好 → L2 管得住 → L3 测得出

ch02_prompt.png

图 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 配置文件
简单 CRUD5-10%各业务模块

判据代码的"意外程度"越高,注释越要多。

意外程度例子注释量
不意外get_user_by_id()0 行
轻度意外dist = -raw_score1-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 个节点的模板),管理的重要性不降反升。

一个反直觉的观察

模型越强,提示词管理越重要 —— 因为:

  1. 应用场景变多 → 提示词数量暴增
  2. 部署规模变大 → 改坏的成本变高
  3. 团队协作 → 需要版本和权限管理

本项目的启示

第 013 次提交花 1724 行做提示词管理,第二天就靠它修了幻觉(014)。

如果当时相信"提示词工程过时了"而不做这套管理:

  • 修幻觉要改代码 + 重启 → 至少拖一天
  • 后面 13 个节点的模板无法统一管理
  • Ch18 的评测体系无法落地

一句话"写好提示词"这个手艺在贬值,"管好提示词"这个工程能力在升值。

就像编程语言从汇编进化到高级语言——写代码的技巧简化了,但软件工程(版本、测试、部署)反而更复杂更重要了


本章小结

收获内容
一个层次L1 写得好 → L2 管得住 → L3 测得出(L2 是 L3 的前提)
一个诊断闲聊幻觉的根因在路由不在提示词 —— 从结构上分流,不从措辞上约束
一个技巧渲染用白名单替换,不用 str.format()
一个原则注释的"意外程度"决定注释量;只写"为什么"可降低腐烂率
一个认知只有 1/4 的幻觉适合用提示词解决

下一章Ch03 · 工程卫生 —— 提示词管住了,但它跑在什么环境上?密钥怎么不泄露、依赖怎么选、文档怎么留痕(覆盖提交 003、004、005、009、017、018)。


导航返回总目录 · 上一篇:大模型接入 · 下一篇:工程卫生 →