你跑着三个 LangGraph 代码审查 Agent 实例:生产用 prompt v1.2,staging 用 v1.3,金丝雀实例用 v1.4。每个版本的 system prompt 都有细微差别:v1.2 检查安全问题,v1.3 加了性能检查但丢掉了格式规则,v1.4 恢复了格式但改了一个输出 schema 的字段名。审查质量随处理 PR 的实例不同而剧烈波动,而且没人说得清版本之间到底改了什么——因为这些 prompt 是散落在代码库各处的 triple-quoted Python 字符串。
最快修复: 别再按 latest 加载 prompt。固定到一个明确的、不可变的版本(git tag、Prompt Hub 的 commit hash,或 Langfuse 的 production label),并在每次运行时记录确切的 prompt 版本和它的 SHA-256 hash。一旦你能从每个请求里读到 prompt_version 和 prompt_hash,漂移就从悬案变成了一行 diff。本文剩下的部分讲怎么做到这一点,以及怎么防止复发。
先判断你属于哪一类
动手前,先把症状对到根因上。
| 你观察到的症状 | 最可能的原因 | 跳转 |
|---|---|---|
| 相同输入下两个实例给出不同输出 | 加载了 latest/未固定的 prompt;实例启动时间不同 | 原因 3、Step 3 |
| 输出格式随机退回成自由文本 | 模板占位符被改名,旧调用点渲染成空白 | 原因 4、Step 4 |
| 一次「小改动」prompt 后某类 PR 质量回退 | 没有 golden-output 回归测试 | 原因 6、Step 5 |
| 说不清是哪个 prompt 产出了坏结果 | 运行日志里没有 prompt 版本/hash | 原因 1/5、Step 2 |
| prompt 变了但没有对应的 PR 或 diff | inline 字符串或远程配置被绕过流程改动 | 原因 1/2、Step 1 |
常见原因
1. Prompt 以 inline 字符串散落各处,没有版本追踪
system prompt 是 agents/reviewer.py 里的一个 triple-quoted 字符串。开发者改它时,改动跟着代码 commit 一起进去,没有独立历史、没有 changelog,除非审查者专门打开 Agent 文件,否则 PR 里看不到这处 diff。
怎么判断: 在 Agent 文件里搜多行字符串。grep -rnE 'You are|SYSTEM_PROMPT|system_prompt\s*=\s*("""|f""")' agents/ 能把没有版本追踪的 prompt 翻出来。出现 5 处以上独立定义,就说明没有单一可信源。
2. Prompt 由多个来源动态拼接,却没有 manifest
最终的 prompt 由一个 base 模板、一段任务专用片段和一个格式说明块拼成,每一块来自不同文件或函数。没有 manifest 记录某次运行用的是哪种组合,所以只要其中一块不同,不对比全部三个来源就发现不了。
怎么判断: 搜 prompt 的拼接或 f-string 组装(system_prompt = base + task_prompt + format_block)。如果最终拼好的整段 prompt 没有作为一个字符串记下来,你就无法知道某次运行用的确切组合。
3. 按 latest 拉取 prompt,完全没有固定版本
Agent 在启动时从配置服务或 registry 拉 prompt,比如 GET /prompts/reviewer/latest、client.pull_prompt("org/reviewer"),或者不带 label 的 langfuse.get_prompt("reviewer")。「latest」/默认值在别人一更新配置的瞬间就变了,于是相隔几分钟启动的两个实例会加载不同的 prompt。URL 里没有版本,也没有记录每个实例加载了哪个版本。这是实例间漂移最常见的单一原因。
怎么判断: 审计每一处 prompt 拉取。凡是标识符为 latest、没有版本/commit/label 后缀、或依赖默认值的调用都标记出来(Langfuse 在不给 label 时默认服务 production label——未设 label 本质上也是一种隐式固定,应该把它写明)。
4. 版本间改了占位符字段名
v1.3 把占位符 {output_format} 改名成了 {response_schema},但漏改了两处调用点。那两处仍传 output_format;用 str.format 时,多余的 kwarg 会被静默忽略,新占位符渲染成空白,于是 Agent 拿到的 prompt 里格式段是空的,输出退化成自由文本。
怎么判断: 把每个模板里的占位符与传给 template.format(...) 或 Template.substitute(...) 的 kwargs 对比。用 str.format 时,缺 key 会抛 KeyError,但多出来/改了名的 key 会被静默丢弃——正是这种不对称是个陷阱。任何不匹配都是漂移信号。
5. A/B 测试框架切换 prompt,却没按运行记录当前变体
流水线在 A/B 测试 prompt 变体,按请求用运行时 flag 切换变体,但运行日志没有记录当时激活的是哪个变体。调试坏结果时,你分不清 Agent 当时跑的是变体 A 还是 B。
怎么判断: 检查变体分配是否写进了运行的 metadata。没有 prompt_variant 字段,就无法把输出质量和激活变体对应起来。
6. Prompt 演进没有回归测试覆盖
开发者改个措辞去优化某个场景,悄悄让另一个场景回退。没有 golden-output 测试,回归不是在 CI 里发现,而是上线后靠用户反馈才暴露。
怎么判断: 在测试套件里搜有没有「用固定输入跑 Agent 并对输出格式或内容做断言」的测试。一个都没有,就意味着 prompt 回归会无声上线。
最短修复路径
Step 1:把 prompt 移到版本化的文件,用 git 管理
agents/
prompts/
reviewer/
v1.2.0.txt
v1.3.0.txt
v1.4.0.txt
CHANGELOG.md # 每个版本改了什么
按明确版本加载:
from pathlib import Path
def load_prompt(agent: str, version: str) -> str:
path = Path(f"agents/prompts/{agent}/{version}.txt")
if not path.exists():
raise FileNotFoundError(f"Prompt not found: {path}")
return path.read_text(encoding="utf-8")
REVIEWER_PROMPT = load_prompt("reviewer", "v1.4.0")
现在 prompt 文件的 git 历史是明确、可搜索的,用 git blame 就能追溯改动来源。优先用纯 .txt/.md,diff 才好读;别把 prompt 埋进 YAML 或 JSON 里——那样空白变化很难审查。
Step 2:每次运行都记录 prompt 版本和 hash
import hashlib
def get_prompt_with_metadata(agent: str, version: str) -> dict:
content = load_prompt(agent, version)
return {
"content": content,
"version": version,
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest()[:16],
}
# 每次 Agent 调用时:
meta = get_prompt_with_metadata("reviewer", REVIEWER_VERSION)
logger.info(
"agent=reviewer prompt_version=%s prompt_hash=%s run_id=%s",
meta["version"], meta["sha256"], run_id,
)
如果你发 OpenTelemetry span,就把它们作为 span 属性,和标准 GenAI 字段(gen_ai.request.model、gen_ai.operation.name)放一起。OTel GenAI 语义约定截至 2026 年 6 月仍处于试验阶段,没有专门的 prompt 版本字段,所以用一个稳定的自定义属性,比如 prompt.version 和 prompt.sha256。两个实例结果不一致时,比 prompt_hash 是确认漂移最快的办法。
Step 3:明确固定 prompt 版本——绝不用 latest
在部署配置里固定,让一次改动像任何改动一样走审查和部署:
# deployment/config.yaml
agents:
reviewer:
prompt_version: "v1.4.0" # 明确固定,绝不写 "latest"
coder:
prompt_version: "v2.1.0"
config = load_deployment_config("deployment/config.yaml")
REVIEWER_PROMPT = load_prompt("reviewer", config["agents"]["reviewer"]["prompt_version"])
如果你用托管的 prompt store,规则一样——固定到不可变引用,而不是会移动的指针:
- LangSmith Prompt Hub(截至 2026 年 6 月):每次
push_prompt都创建一个不可变 commit。要可复现就固定到 commit hash,比如client.pull_prompt("my-org/reviewer:a1b2c3d4")。:production这类 tag 是可变指针;把 staging 提升到 prod,只是把prodtag 移到staging已经指向的那个 commit——不用重新部署。用 tag 表示「当前激活」,需要某次运行精确可复现时用 commit hash。 - Langfuse(截至 2026 年 6 月):版本是不可变的整数(1、2、3…);label(
production、staging)是可变指针。langfuse.get_prompt("reviewer")默认服务productionlabel——把它写明:get_prompt("reviewer", label="production"),或硬固定:get_prompt("reviewer", version=7)。设置cache_ttl_seconds和一个fallbackprompt,这样 registry 故障时行为也不会被悄悄改掉。
要在各处删掉的反模式:生产代码里的 GET /prompts/reviewer/latest,或裸的 pull_prompt("org/reviewer")。
Step 4:在加载时校验所有占位符
import string
class PromptConfigError(Exception):
pass
def validate_and_render(template: str, **kwargs) -> str:
formatter = string.Formatter()
template_fields = {
field for _, field, _, _ in formatter.parse(template) if field
}
provided = set(kwargs)
missing = template_fields - provided
extra = provided - template_fields
if missing:
raise PromptConfigError(f"Template missing kwargs: {missing}")
if extra:
logger.warning("Extra kwargs not in template (renamed placeholder?): %s", extra)
return template.format(**kwargs)
在启动时跑(不是每次调用都跑),这样像 {output_format} 改成 {response_schema} 这种改名能快速失败,而不是在运行时静默渲染出一个空白段。
Step 5:为关键行为写 golden-output 回归测试
GOLDEN_TESTS = [
{
"input": "Review: def login(u, pw): return db.query(f\"SELECT * FROM users WHERE pw={pw}\")",
"must_contain": ["SQL injection", "parameterized"],
"must_not_contain": ["looks good", "no issues"],
"prompt_version": "v1.4.0",
},
]
def test_reviewer_golden_outputs():
for case in GOLDEN_TESTS:
out = run_reviewer_agent(case["input"], prompt_version=case["prompt_version"]).lower()
for phrase in case["must_contain"]:
assert phrase.lower() in out, f"Missing '{phrase}' in: {out[:200]}"
for phrase in case["must_not_contain"]:
assert phrase.lower() not in out, f"Banned '{phrase}' in: {out[:200]}"
在把任何 prompt 版本提升到 staging 之前,先在 CI 里跑这些。因为模型输出会变,断言要针对稳定的子串和 JSON schema 合法性,而不是逐字匹配;或者把断言包进一个带通过阈值的 LLM-as-judge 评估里。
如何确认已修复
- 对同一类输入,把所有实例的日志拉出来,确认每一行的
prompt_version和prompt_hash都相同。整个集群 hash 一致,就说明没有漂移。 - 给每个实例发一条完全相同的请求,对比响应;格式和关键字段应当一致。
- 在 CI 里跑一个漂移检查,把每个 registry/生产 prompt 的 hash 与
deployment/config.yaml里固定的版本对比,不一致就让构建失败。 - 在任何提升之前,确认固定版本的 golden-output 套件是绿的。
预防建议
- prompt 存成版本化的文本文件并纳入源码管理,或存进托管 registry 并固定到不可变引用——绝不用临时的 inline 字符串。
- 每次运行都记录确切的 prompt 版本和 SHA-256 hash,让任何历史运行都可复现。
- 在部署配置里明确固定;在生产代码里禁用
latest、裸pull_prompt(...)和未设 label。 - 在启动时校验模板占位符;变量不匹配时大声失败,而不是渲染出空白段。
- 每个 Agent 维护一份
CHANGELOG.md,记录每个版本增、删、改了什么。 - 做 A/B 测试时,把变体写进运行 metadata,并把变体对质量指标作图。
- 像审查代码一样审查 prompt 改动——prompt 文本是生产逻辑,不是文档。
- 跑一个定时漂移检查(CI 或 cron),把运行中的 prompt hash 与固定版本对比,让绕过流程的改动在一个部署周期内就被抓到。
常见问答 (FAQ)
Q:prompt 应该和使用它的 Agent 代码分开做版本管理吗? A:应该。prompt 变更的原因(质量调优、新需求、指令 bug 修复)和代码变更(功能、重构)不同。分开做版本,能让你回滚 prompt 回归而不动代码,反之亦然。把 prompt 文件放同一个 repo 里以便原子 PR,但给它们独立的版本号和 changelog。
Q:50 个以上 Agent 的 prompt 怎么管理?
A:用一个 registry,给每个 prompt 存版本、agent ID、环境 label 和内容 hash。Agent 在启动时固定并拉取自己的版本并缓存。截至 2026 年 6 月,LangSmith Prompt Hub(不可变 commit + 可变 :production/:staging tag)和 Langfuse(不可变版本整数 + production/staging label)都支持这一套,并提供版本间的 diff 视图。再加一条 lint 规则,禁止在 registry 之外定义 prompt 字符串,没人能绕过去。
Q:prompt 应该放在代码里还是数据库/registry 里?
A:如果只有工程师改它,放代码更好:版本控制、PR 审查、git blame 都免费。如果非工程师(市场、客服)必须实时改,那就用带强制版本字段和审计日志的 registry。无论哪种,都绝不能让生产跑未固定的 prompt。
Q:怎么快速回滚一个坏的 prompt 版本?
A:用文件时,把 deployment/config.yaml 里的 prompt_version 指回上一个 tag 并重新部署。用 registry 时,移动那个可变指针:LangSmith 里把 :production tag 移到上一个 commit;Langfuse 里把 production label 移到上一个版本——不用部署代码。然后跨实例校验 hash,并重跑 golden 套件,确认回归已消失。
Q:怎么评估一个新 prompt 版本是不是真的更好? A:建一个 20-50 条输入、带评分预期输出的评估集。跑两个版本并比分。只有当新版本提升了平均分、且没有任何单条用例掉到旧版本分数以下时才提升。LangSmith 和 Langfuse 都支持针对某个特定 prompt 版本在数据集上跑评估。