修复不同版本 Agent 间的 Prompt 模板漂移

不同 Agent 实例运行着略有差异的 system prompt,输出质量取决于哪个实例处理了请求。本文教你如何固定 prompt 版本、按运行记录确切的 prompt,并在上线前发现漂移。

你跑着三个 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_versionprompt_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 或 diffinline 字符串或远程配置被绕过流程改动原因 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/latestclient.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.modelgen_ai.operation.name)放一起。OTel GenAI 语义约定截至 2026 年 6 月仍处于试验阶段,没有专门的 prompt 版本字段,所以用一个稳定的自定义属性,比如 prompt.versionprompt.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,只是把 prod tag 移到 staging 已经指向的那个 commit——不用重新部署。用 tag 表示「当前激活」,需要某次运行精确可复现时用 commit hash。
  • Langfuse(截至 2026 年 6 月):版本是不可变的整数(1、2、3…);label(productionstaging)是可变指针。langfuse.get_prompt("reviewer") 默认服务 production label——把它写明:get_prompt("reviewer", label="production"),或硬固定:get_prompt("reviewer", version=7)。设置 cache_ttl_seconds 和一个 fallback prompt,这样 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 评估里。

如何确认已修复

  1. 对同一类输入,把所有实例的日志拉出来,确认每一行的 prompt_versionprompt_hash相同。整个集群 hash 一致,就说明没有漂移。
  2. 给每个实例发一条完全相同的请求,对比响应;格式和关键字段应当一致。
  3. 在 CI 里跑一个漂移检查,把每个 registry/生产 prompt 的 hash 与 deployment/config.yaml 里固定的版本对比,不一致就让构建失败。
  4. 在任何提升之前,确认固定版本的 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 版本在数据集上跑评估。

相关阅读

标签: #AI 编程 #Agents #排查