情况 2:更危险 ------ 内容里的占位符被意外替换
问: 等于该符号, 用户宣称: 用户发出的询问为, {query} 这个内容, 其具体所表达的含义是什么, 此句的意思是。
文档内容里的 {query} 可能被替换,造成内容错乱
2.6 兜底与校验:别让一次手滑打翻全站
兜底------渲染后检查是否还有未替换的占位符:
re
def (, **):
=
for key, value in .items():
渲染后检查是否还有未替换的占位符
= set(re.(r"\{(\w+)\}", ))
if :
.(f"模板存在未替换变量: {}")
生产环境降级(填空),开发环境应报错
校验------防止后台改错变量名:
等于, 一个包含"query", 其值为空, 另有两个空值, 还有"role的值"也为空的集合。
def (: str) -> tuple
bool, str
"""校验模板中的占位符是否合法(后台保存前调用)。"""
= found -
if :
False, f"未知变量: {},可用: {}"
True, "OK"
2.7 改造前后的账本第二部分小结 · 对照自查
到此地, 目标2达成------提示词已然「能存进、可改动、能退回」。然而搬至数据库的价值, 并非仅在「改起来便捷」, 而是在于其使你次日便修复了一个棘手的bug。此bug即为第三部分的主角色。
第三部分 · 第一类幻觉:不该检索时检索
这一部分成功获取目标3, 这还是全章最为违背直观感受的一处要点。简单一句话来透露剧情: 这个幻觉, 调节提示词是没有作用的。
3.1 现象:闲聊也编出「知识库腔」
用户曾问"你好", 用户亦问"你是谁", 系统皆会去检索知识库, 之后编造一段呈现"知识库风格的回答"。
用户:你好
系统, 依据文档的第 2.1 节, 你好!此设备具备支持多种通讯协议的能力...有悖常理。
3.2 根因:不是提示词的问题,是路由的问题
这是本章最关键的一点。
错误的诊断:"提示词不够强,要加更严格的约束。"
正确的诊断:系统在该走"直接回答"的时候走了"检索回答"。
你好被用户问了之后, 会被判定为, 然后去进行线索检索, 接着编造基于文档的回答。
↑ 根因在这里
对于只在提示词里添加约束来说, 假设若是"如果是闲聊就直接回答"这种情况, 所得出的效果具备一定局限性, 原因归之为: 一方面, 这个拥有获得一堆文档契机的模型, 常常会表露出"把这些文档加以运用"的倾向;再者, 随着约束强度增大, 模型在两个指令之间左右摇摆的可能性就越大。
正确的修法:在意图识别阶段就分流,闲聊根本不进检索分支。
跟 ch01 相对照, 上一章你所排除掉的是第 2 类幻觉, 即检索到了但截断没读进去, 而这一章所排除的是第 1 类, 也就是压根不该去检索。两类之中, 一类在于能不能看见, 另一类在于该不该去看, 这些都是工程层面出现的错误, 并非模型本身的问题。
3.3 修法: 分支
.py:882 ------ 三条分支
graph.s(
"",
self.,
"": "", # 简单查询 → 多轮检索
"": "", # 复杂查询 → 多智能体拆解
"": "", # 闲聊 → 直接回答(不检索!)
},
.py:925
graph.("", "") # 闲聊直答后直接出口
对于其节点而言,完全不存在检索这一环节, 在结构方面, 这般杜绝了"闲聊编造文档内容"的情况。
3.4 意图识别的实现(三代技术的第一代)
第 014 次提交时用的是快速分类(词表):
.py:2438
自定义方法, 该方法接收一个查询字符串作为参数且返回一个字符串。句末有标点符号。
"""快速分类:基于词表的规则判定。
这是三代意图识别技术里的第一代(详见 Ch11):
第一代 关键词/词表 ← 本章
第二代 LLM 分类
第三代 向量语义路由 ← 最终形态
"""
.py:979
def (self, state: ) -> dict:
"""意图识别节点 ------ 系统的分叉口。"""
.py:1158
def (self, state: ) -> str:
"""根据意图返回下一个节点名(必须命中 的键)。"""
这次所提交的, 有着 119 行改动之处, 其中 113 行存在于 .py 当中------这意味着"提示词管理"此次的改造, 直接促使幻觉修复获得了加速。这恰恰是 L2 能力的价值得以兑现的体现: 要是不存在版本号后台, 那么这 113 行提示词, 仍旧得走上「改代码 + 重启」的旧途径。
3.5 幻觉的四种类型:先分类,再下药
类型
表现
修法
章节
① 不该检索时检索
闲聊/OOS 也去查文档
路由分流
本章
② 检索到了但没读进去
文档在 里但被截断
/ 出口契约
Ch01、Ch12
③ 检索到了但理解错
模型误读数字/单位
约束 + 强模型
Ch20
④ 检索不到就编
查空也硬合成答案
验收门四态判定
Ch23
不少人一旦碰到幻觉便做调整提示词的举动, 但事实上唯有第③类适宜运用提示词予以解决, 要先进行分类, 而后再对症下药。
第三部分小结 · 对照自查
到此处, 目标3达成, 即模型知晓了「何时不应去查询」。然而, 本章尚有一笔轻易可处理的账需要结算, 那便是提示词属于文本资产, 注释同样属于文本资产。如此一来, 便进入了第四部分。
第四部分 · 另一类文本资产:注释
这一部分达成目标 4。提示词得加以管理, 注释同样如此------它们全数是「撰写出来供人和机器去看」的文本资产。不同之处仅在于: 提示词是写给模型看的, 注释是写给人看的。
4.1 四个等级:什么样的注释才有价值
第 002 次提交只改了 13 行,但确立了注释的标准:
4.2 一个模板:六要素注释
给第 958 行、第 010 次所提交的代码补了 1295 行注释(注释与代码比例为 135%), 以 MMR 算法当成示例, 最后形成的注释模板是。
.py:2590
看起来你提供的内容不太完整且格式有误, 不太能按照要求准确改写。请你检查并提供正确完整的内容。
"""
【辅助:MMR ( ) 重排序】
其职责在于, 做一种文档排序算法, 该算法要在相关性以及多样性之间达成平衡。
原理: ← ② 为什么需要
有多个文档, 其返回容易, 是内容相似的(来自同一段落的不同切片), 属于向量检索。
把所有内容都塞给 LLM 的话, 不仅会造成 token 空间的浪费, 还会让答案倾向于重复的内容。
MMR 同时优化两个目标: ← ③ 核心机制
-
相关性():选与查询最相关的文档
-
多样性():选与已选中文档不重复的新文档
算法(贪心): ← ④ 实现步骤
-
按距离排序,选最相关的作为第一个选中项
-
循环选择剩余项:
a. 计算它和已选中集合的最大相似度()
b. MMR 分数等于, λ 乘以相关性, 减去, (1 减去 λ)乘以冗余度。
c. 选 MMR 分数最高的加入选中集合
λ(=0.7)的含义: ← ⑤ 参数含义
-
越接近 1 越看重相关性,越接近 0 越看重多样性
-
0.7 是经验值,7:3 的权衡
参数:query / / k ← ⑥ 签名说明
返回:MMR 排序后的文档列表
"""
存在六个要素, 分别是职责, 之后是动机, 再之后是机制, 如果接着是步骤, 然后是参数, 最后是返回值。
4.3 判断准则:意外程度决定注释量
判据:代码的"意外程度"越高,注释越要多。
记忆的口诀是, 函数名要是能够阐述清楚明白的, 那就不要去写, 函数名到截止的那个范围的, 那就一定得去写, 注释仅仅只需写为什么, 而不要去写怎么做原因是怎么做是会发生变化的, 但是为什么是极少会产生变动的。
4.4 这笔投资的回报
时点
场景
注释的价值
第 011 次提交
加记忆系统,要改节点
不用重读实现,看注释就知道在哪插
第 028 次提交
修记忆 bug
注释写了"身份贯通"的设计前提
第 055 次提交
修 Bad Case #9
出口契约注释直接指出问题
第 069 次提交
改语义路由
路由节点注释说明三层降级设计
现在
写这份教程
80% 内容来自这些注释
以半天的投入, 收获持续于整个项目周期的回报。这一回的"非功能"提交, 是此番项目里投资回报率最高的状况。
第四部分小结 · 对照自查踩坑总表
走到此处, 四个目标皆已成功达成。将这一章里所遇到的难题罗列于一张表格之上------每一项都是切实亲身经历过的:
现象
根因
修法
占位符与内容花括号冲突
文档含 {...} 时 str.() 抛
内容里的花括号被当成占位符
白名单逐个替换(见 2.5)
缓存失效不及时
后台改了模板,服务还用旧的
缓存 key 没带版本号
key = f":{name}:v{}"
多实例缓存不一致
4 个 ,改了模板有的生效有的没生效
各进程独立内存缓存
Redis 主动失效 / 缩短 TTL / 版本号轮询
后台改错变量名
{query} 写成 {quer},全站问答报错
无校验
四道防线(校验 / 兜底 / 恢复默认 / 灰度)
注释腐烂

改了代码没改注释,注释描述三个月前的逻辑
注释写了「怎么做」
只写「为什么」+ 关键断言写成可执行代码
这样的一张表, 是值得进行截图并且好好保存下来的。心甘情愿地将这些坑, 一个接着一个地提前去填平,而这, 便是工程化AI与玩具demo之间的分水岭了。
学习目标回顾
回到开头的四个目标,逐一自查:
四个全过,这一章就没白读。最后送一句话带走:
「写好提示词」属于手艺范畴, 「管好提示词」则归为工程领域。手艺存在过时一说, 工程却并非如此, 提示词数量越多, 部署规模越大, 改坏后代价越高昂, 管理也就越发具有价值有其贵重之处。
而要将提示词管理好的第一步, 始终都是先要去承认, 提示词所起到的作用仅在于约束「回答的方式」, 并不会去判定「是否进行回答」, 至于回答与否, 那是关于路由相关方面的事情。
知识点卡片【知识点】提示词工程的三个层次
L3 测得出 ← 离线评测集 / A/B(证明"变好了")
↑ 依赖
L2 管得住 ← 版本 / 灰度 / 回滚 / 可视化
↑ 依赖
L1 写得好 ← 措辞 / few-shot / 思维链
L2 作为 L3 的前提究竟是为何呢? A/B 测试明确规定要求两组采用不一样的版本提示词。若进行硬编码操作, 那么就必须部署两个实例才能够实现对比, 然而该成本实在是极高。当进入数据库之后, A/B 仅仅只是依据流量比例来选取不同的内容。
【知识点】幻觉的第一类:不该检索时检索
具备一种特性, 那就是当用户提出有关闲聊或者OOS方面的问题之后, 系统会展开检索的动作, 进而去编撰成类似那种知识库风格般的回答。
为啥调提示词没效果呢? 是由于模型已然获取到了文档, 它会趋向于"运用"这些文档。你增添约束讲"闲聊就径直回答", 模型反倒会在两条指令之间来回摆动。
正确修法:在路由层分流 ------ 闲聊根本不进检索分支:
{"": ""} # 节点没有检索环节
从结构上杜绝,而不是从措辞上约束。
【知识点】注释的"意外程度"原则
代码越意外,注释越要多。
判定方式: 去设想一位跟你水准不相上下的共事者阅读这段编码, 他会困在哪一处地方? 于每一个困住的地方撰写注解。
反例(浪费):
def ():
"""根据 ID 获取用户。""" # 函数名已经说清楚了
正例(必要):
BM25 取负:系统内统一约定"距离越小越相似",
不是"越大越不相似 ", 而是"越大越相似", 这才是BM25原始分数的情况。要是漏掉负号, 就会致使排序出现完全反向的状况。
dist = -
记忆口诀:函数名能说清的,别写;函数名为止的,必须写。
练习题基础题
- 有人针对用户问"你好"后系统按照依据文档第 2.1 节回复"您好!本设备支持多种通讯协议"的情况, 说这是提示词不够强, 对此进行评价, 这个诊断。
答案
这个诊断是错的。根因不在提示词,在路由。
正确诊断:系统在该走"直接回答"的时候走了"检索回答"。
用户问"你好" → 判为 → 走检索 → 编造基于文档的回答
↑ 根因在这里
为什么调提示词效果有限?
基于已拿到一堆文档的模型情况, 在这里添加"如果是闲聊就直接回答"这样的约束条款后, 模型会面临指令 A(隐含着要依据文档进行回答)与指令 B(明确表示闲聊就依此直接回答)这两个相互冲突的指令。
模型会在两者间摇摆,表现不稳定。
正确修法:在意图识别阶段分流,闲聊根本不进检索分支:
graph.s("", , {
"": "", # 节点没有检索环节
})
从结构上杜绝,而不是从措辞上约束。
通用的原则是, 某个问题, 要是反复借助"加约束"这个办法都解决不了, 那就得问问自己, 具体是不是在错误的那一层次上面解决该问题呢?
提示词是"生成层"的手段,而这个问题出在"路由层"。
- 为什么渲染提示词模板时不能用 str.()?
答案
两个原因:
原因 1:文档内容含花括号 → 抛异常
等于, 配置示例冒号, 左花括号,双引号空字符串冒号 30, 逗号, 重试冒号 3, 右花括号, 单引号。
= "上下文:{}"
会把 {""...} 当成占位符
.(=) # : ''
原因 2:更危险 ------ 内容里的占位符被意外替换
等于由用户提出的问, 问的内容是, {query}的所表达的意思究竟是什么, #在文档内容当中实际含有{query}。
= "上下文:{}\n问题:{query}"
= .(=, query="心跳间隔")
在某些特别的渲染顺序之时, 里的带有特定条件的 {query} 极有可能会被进行替代, 进而致使内容陷入错乱的状况。
正确解法:白名单逐个替换
def (, **):
=
for key, value in .items():
= .("{" + key + "}", str(value))
只替换模板声明过的变量,内容里的其他花括号原样保留。
这种时候, 当把那 "不可控的输入" 也就是文档内容, 与 "可控的模板" 放在一块儿进行处理之际, 对于这个坑的本质而言, 一定要明确其边界, 这是必然的。
- 用于提示词工程的L1是什么, 用于提示词工程的L2是什么, 用于提示词工程的L3是什么, 为何用于提示词工程的L2会处于用于提示词工程的L3的前提状况呢?
答案
层次
内容
L1 写得好
措辞、few-shot、思维链 ------ 决定单次效果上限
L2 管得住
版本、灰度、回滚、可视化 ------ 决定能否持续优化
L3 测得出
离线评测集、A/B 测试 ------ 决定优化方向对不对
为什么 L2 是 L3 的前提?
有关于A/B测试的技术要求, 两组流量得选用不同版本的提示词, 并且能够分别对效果进行统计哎。
同样的道理, L3的"离线评测"也是依靠L2的。是因为这个评测需要批量去运行不同版本的提示词, 要是采用硬编码的方式, 那么每测试一个版本就都得重新进行部署。
第一点, L1起着决定上限的作用, 第二点, L2决定着能否持续逼近上限, 第三点, L3决定你是不是朝着正确方向前行。
进阶题
- 部署了4个服务, 后台更改提示词后, 有的示例生效了, 有的示例没生效, 给出三种解决方案以及取舍。
参考答案
原因在于, 每一个都具有独立的内存缓存, 并且, 后台改动的是数据库, 然而, 各个进程的缓存对此并不知悉。
方案 1:缩短缓存 TTL(最简单)
= 10
def get(name):
如果名字在缓存之中, 并且当前时间减去缓存时间小于某个值。
cache
""
重新加载
方案 2:Redis 集中缓存 + 主动失效(推荐)
def get(name):
= redis.get(f":{name}")
if is None:
= (name)
redis.set(f":{name}", )
def (name, ):
db.(...)
拿 redis 来说, (f":特定的那个 name") # ← 重点在于其中的关键部分: 主动产生失效的情况。
方案 3:版本号轮询(无 Redis 时)
def get(name):
=数据库操作对象.("从哪里当名字等于?时",名字)。
如果浏览器缓存中获取名为name的内容, 且该内容中获取空字符串的值, 其结果不等于冒号。
cache = {"": , "": (name)}
cache
""
推荐的组合是, 存在着能够实现毫秒级响应的本地缓存, 有着以秒级为单位的Redis版本号, 并且在后台得以进行修改时会主动执行DEL操作。
这个项目起始之时为单实例状态, 并未遭遇此问题。然而, 在第 033 次呈交之物被引入"多"的情况后, 这般设计成为了必然之需。
- 原来, 是在后台进行提示词修改操作的时候, 把那个变量名给打错了, 结果, 就致使线上所有的问答都出现了报错, 为此要设计四道防线。
参考答案
防线 1:编辑时校验(前端 + 后端)
= {"query", "", "", "role", ""}
def (: str) -> tuple
bool, str
found = set(re.(r"\{(\w+)\}", ))
= found -
if :
False, f"未知变量: {},可用: {}"
True, "OK"
前端方面, 当进行输入这个行为的时候, 下拉补全那些可以使用的变量, 后端呢, 在保存之前要进行校验, 要是不合法那就直接拒绝。
防线 2:渲染时兜底(不崩)
= set(re.(r"\{(\w+)\}", ))
if :
.(f"模板存在未替换变量: {}")
for var in :
= .("{" + var + "}", "")
存在这样一个关键决策, 即未替换变量究竟是选择"填空", 还是选择"报错"呢? 其开发环境呈现出严格报错的设定, 这能使问题立刻暴露出来。而生产环境则采用降级填空并伴有告警的方式, 以此保证服务处于可用状态。
防线 3:一键恢复默认
def (name):
= # 默认模板硬编码在代码里
db.(name, =, =+1)
cache.(name)
作兜底之用, 默认模板被硬编码于代码内, 而数据库里所存的仅仅是那 "当前生效版本"。
防线 4:灰度发布
全新的模板, 致使百分之十的流量, 去观察那五分钟内出现的错误率, 若情况正常便放大至百分之百, 要是出现异常则会自动回滚。
要搭配 Ch18 的评测系统, 这系统要能够迅即评判新的版本究竟是更佳, 还是状况不如往昔。
那种最小的、可以被使用的组合是, 防线 1(校验), 再加上防线 3(让其恢复到默认状态), 它能够将 90%的事故阻挡住, 而且所需要耗费的成本是很低的。
本项目实现了防线 1(部分)、2、3,未实现 4。
思考题
- 给本项目的958行代码, 写了1295行注释, 注释与代码比为135%, 这属于过度注释吗, 请给出判断依据。
参考答案
不是过度注释。三条判断依据:
依据 1:看代码类型
. py这是用于编排还有与算法混合而成的文件, 其中存在着拥有13个节点的那种 业务逻辑, 这逻辑是怎样的还得去说明职责以及输入输出情况, 这儿还有MMR、RRF、query等那样的算法, 这类算法具体又是怎样的还得去说明原理以及参数才行, 另外, 还有3个路由函数的分支策略, 这种策略背后的兜底理由是啥也需要去说明。
这类代码的"意外程度"普遍偏高,高注释密度合理。
比照那种情况来讲, 要是面临的是CRUD相对集中的.py文件形势而言, 135%这样的比例明显状态显著偏向过度行列范畴范畴之内了。
依据 2:看内容而非数量
1295 行注释的分布:
L0 复述代码 ~0% ← 没有浪费
L1 重复函数名 ~10% ← 少量冗余
L2 解释为什么 ~60% ← 有价值
L3 决策与陷阱 ~30% ← 高价值
90% 有实质信息量。
依据 3:看后续回报(事后验证最有力)
在后续的33天当中, 这1295行注释: 支撑了第011次提交(加记忆)的很快改动, 加记忆是此次提交的一项内容;支撑了第028次提交(修bug)很精准的定位, 但不是模糊定位, 就像给问题找到了精确坐标, 精准度非常高;支撑了第055次提交(Bad Case #9)的根本原因分析, 是要挖掘深层次底层逻辑性原因而不是表面原因那种剖析过程;能看出来的是, 它也支撑了这份教程的写作, 其中80%的教程内容是直接源自这样的注释, 不是间接或者通过其他因素辗转等来的。
如果当时省下这半天,后面要多花几十倍时间重读代码。
但确有可改进之处:
属于 L1 的些许注释存在像是"""保存历史节点""" 这样的情况, 这些是属于浪费范畴的, 理想的做法是针对简单节点撰写仅仅一句话, 且将篇幅留给复杂算法。
更好的做法:把关键断言从注释升级为可执行代码:
与其写注释
注意: 应在 区间
不如让代码自己校验
0