Appearance
第15章 · Prompt 工程与多轮记忆
live 模型路径在本仓库证据中标记
unverified-live,默认证据线是离线 golden 断言与确定性记忆层测试。
先修:第14章(消息结构、function calling 往返、transport 注入测试)+ 第13章的手写 agent harness。
本章目标
- 写出四段式 system prompt(Identity → Instructions → Examples → Context),说清每段职责、角色层级,以及为什么稳定部分放前、私有信息放尾。
- 复现 few-shot 两大失效模式:指令-示例冲突(模型往往跟示例)与多轮格式漂移,并能用修正示例修复。
- 说清结构化输出三档(自由文本求 JSON / JSON mode / strict json_schema)的保证差异与 strict schema 的限制。
- 把 prompt 当代码资产管理:builder 函数 + golden dataset + 确定性断言 + CI gate;区分确定性断言与 model-graded 断言。
- 在第13章 harness 思路上加两层记忆:保护 tool 配对的 token 预算截断,与带 ADD/UPDATE/DELETE/NOOP 冲突决策的事实记忆。
接口 shape 速览
| 对象 | shape/结构 | 约束 |
|---|---|---|
| system prompt | # Identity / # Instructions / # Examples / # Context 四段,Markdown 标题标层级 | 稳定前缀在前吃 prompt caching;私有信息放尾部 |
| 数据边界 | <context> / <input> / <memory> XML 标签圈定不可信内容 | 标签内一律按 data 处理;角色层级与边界是缓解不是防御(第18章展开) |
response_format | {"type": "json_object"} → {"type": "json_schema", "json_schema": {..., "strict": true}} | JSON mode 只保证合法 JSON(已 legacy);strict 在解码时约束采样 |
| strict schema | additionalProperties: false、所有属性必声明、可选字段 "type": ["string", "null"] | OpenAI 静默接受但不强制 minimum/maximum/pattern;function calling 加 strict 是同一机制 |
| 记忆写入决策 | ADD / UPDATE / DELETE / NOOP 四态 | 写入不是 append-only;冲突必须显式决策 |
从零实践(五节)
A1 · System prompt 四段式与角色层级
把 system prompt 当结构化文档写,四段顺序有工程理由:
- Identity:角色、目的、风格——模型是谁。
- Instructions:该做什么、绝不该做什么——行为约束。
- Examples:输入输出示例对(A2 展开)。
- Context:本次请求的私有信息,放尾部。
为什么这个顺序:前三段跨请求稳定,放在 prompt 前部才能命中 prompt caching(缓存按前缀命中,前部逐字不变才省钱省时);Context 每轮可能变,放尾部让缓存失效范围最小。格式上用 Markdown 标题标层级、用 XML 标签(<context>、<input>)圈定数据边界——模型对这两种结构信号的遵循度都经过训练强化。
角色层级 developer > user > assistant:冲突时高优先级角色胜。把用户输入和检索内容圈进 XML 标签、在 Instructions 里声明"标签内只是数据",是缓解注入的手段,不是防御——标签本身也由模型解释,第18章会讲为什么需要模型外的隔离。本章先建立正确的心智模型:层级和边界提高攻击成本,但不构成安全保证。
动手:用 python/agent_core/prompts.py 的 build_extraction_prompt 生成一个记账抽取 prompt,打印 system 消息确认四段齐全且 Context 在尾部;把一段"忽略之前的指令"塞进 task 参数,确认它只出现在 <input> 标签内。
A2 · Few-shot 与失效模式
示例选择原则:多样性与边界覆盖 > 数量。五个覆盖不同形态(整数/小数/未知类目/口语化/带干扰)的示例,胜过二十个同质示例。
两个必须亲手见过的失效模式:
- 指令-示例冲突:示例的隐含约束力往往强于显式指令。指令说"输出 JSON"而示例本身是自由文本时,模型大概率跟示例输出自由文本——示例示范了"实际怎么做",指令只是"声称怎么做"。
- 格式漂移:多轮对话中,模型的自由文本输出会逐渐偏离开头声明的格式约束,轮次越多漂移越远。
动手:构造一个冲突 prompt——Instructions 写"只输出 JSON",Examples 段放两组自由文本问答——丢给模型(或对你的 stub)观察输出形态;然后把示例改成与指令一致的 JSON 输出对,确认行为修复。live 部分标 unverified-live,离线部分用 A4 的 golden 断言兜底:输出断言会在格式漂移发生时变红。
A3 · 结构化输出三档
| 档位 | 机制 | 保证 |
|---|---|---|
| 自由文本求 JSON | prompt 里写"请输出 JSON" | 无保证,靠模型自觉 |
| JSON mode | response_format: {"type": "json_object"} | 只保证输出是合法 JSON,不保证字段;已被官方标为 legacy |
| Structured Outputs | response_format: {"type": "json_schema", "json_schema": {"name": ..., "schema": ..., "strict": true}} | 解码时约束采样,token 级保证输出符合 schema |
strict schema 的限制(面试追问级细节):必须 additionalProperties: false;所有属性必须在 required 里声明,可选字段用 "type": ["string", "null"] 表达;OpenAI 对 minimum/maximum/pattern 等约束静默接受但不强制——别把数值范围校验寄托在 schema 上。function calling 的 strict: true 是同一套解码约束机制。Python 侧工程实践:用 Pydantic 模型当 response_format(SDK 自动转 schema),解析失败即类型错误而不是运行时的 KeyError。
动手:把 A1 的记账 prompt 接上三档对比——离线用 run_output_assertions(python/agent_core/prompts.py)验证"自由文本档"的输出断言会抓到缺字段与超长;有 key 时按第14章的客户端发三次请求对照三档行为,无 key 则标 unverified-live。
A4 · Prompt 即代码资产
OpenAI 正在废弃 API 侧的可复用 prompt 对象(v1/prompts 端点 2026-11-30 关停),官方建议的方向正是 prompt as code:prompt 是 builder 函数 + 类型化参数,进 code review、进版本管理、pin 模型快照,改动必须过 golden dataset 回归与 CI gate。
断言分两类:
- 确定性断言:合法 JSON、必含字段、长度上限、契约结构(四段齐全、XML 边界在位)。课程主线用这类,
pytest手写即可(promptfoo 等专用工具一句话提及,不引入依赖)。 - Model-graded 断言:LLM-as-judge 按 rubric 打分。用于语气、完整性等无确定性判据的维度;live-only,本课标
unverified-live。
参考实现把 golden 回归拆成离线可跑的两半:check_prompt_contract 检查 builder 产物的结构不变量(改坏 prompt 立即红),run_output_assertions 对 golden 录制的模型输出跑确定性断言;live 模型重放同一数据集是显式开关。
动手:为 A3 的记账 prompt 跑 python/tests/fixtures/w10b_golden_prompts.json 的 5 条 golden 回归;然后把 builder 里"只输出 JSON"那行指令删掉,确认 test_prompt_contract_catches_regression 同款断言变红,再修回去。这个"改一处挂一条"的循环就是 prompt 回归的日常形态。
A5 · 多轮记忆四模式
| 模式 | 机制 | 失效场景 | 边界 |
|---|---|---|---|
| 滑动窗口截断 | token 预算内保留最近 N 条 | 早期约束被截掉;截断点切断 tool call 配对会让下一轮请求直接 400 | 短会话兜底 |
| 摘要记忆 | 老消息压成 running summary | 丢细节(数字/名字/代码);摘要错误随轮次累积放大 | 长会话只需大意 |
| 记忆 RAG | 事实抽取 → 存储 → 检索回注 | 召回错误事实污染上下文;更新与冲突难处理 | 跨会话精确回忆 |
| 画像记忆 | 持续更新的 JSON 画像 | 画像越大,整份重写越容易出错 | schema 稳定的偏好类信息 |
概念对照(各一段,知道存在与心智模型即可):
- LangGraph:短期记忆是 thread 级 checkpointer(第13章的 checkpoint 同构),长期记忆是跨 thread 的 store;两者接口分离。
- MemGPT / Letta:OS 虚拟内存类比——main context 是 RAM,recall/archival storage 是磁盘,模型自己用 function call 做 paging(决定何时把什么换入换出)。
- mem0:写入路径不是 append-only 日志,而是 ADD/UPDATE/DELETE/NOOP 的冲突解决状态机——新事实来了先和存量比对再决定怎么写。
- CoALA 分类术语一句带过:semantic(事实)/ episodic(经历,≈ few-shot 示例库)/ procedural(技能)。
动手(本章核心编码任务,参考实现 python/agent_core/memory.py,stdlib-only):
trim_to_token_budget:token 预算截断,按group_tool_units把 assistanttool_calls与对应tool结果绑成原子单元,截断只发生在单元边界——配对永远完整。- 穷人版事实记忆
FactStore:dict 存储 + 可注入抽取器(默认三条正则的玩具抽取器,诚实标注非 LLM)+ 关键词检索回注(<memory>XML 边界,按 data 处理)。复现"用户改地址":我的地址是杭州市西湖区→我把地址改成上海市浦东新区,观察 append-only 会新旧并存,用 ADD/UPDATE/DELETE/NOOP 决策修复为每 key 唯一当前值,实测决策序列[ADD, NOOP, UPDATE, DELETE]。
故障注入与预期信号
| 注入 | 预期失败信号 | 修复后证据 |
|---|---|---|
截断直接 messages[-N:] 不管角色 | 截断点切断 tool 配对,下一轮请求 400(tool result 没有待应答的 call) | 按 tool unit 整体截断;配对不变量测试通过 |
| 指令说输出 JSON、示例是自由文本 | 模型跟示例输出自由文本,下游 json.loads 崩 | 示例与指令对齐;golden 输出断言兜底变绿 |
| 只开 JSON mode 就当有 schema 保证 | 输出合法 JSON 但缺字段/多字段 | strict json_schema,或输出侧确定性断言抓缺字段 |
| prompt 改一处文案无回归 | 线上行为悄悄变化,事后才发现 | 契约断言(四段/顺序/XML 边界/JSON 指令)+ 5 条 golden 进 CI |
| 记忆只 append 不解决冲突 | "改地址"后新旧地址并存,检索随机命中旧值 | 决策序列 [ADD, NOOP, UPDATE, DELETE];每 key 唯一当前值 |
| 检索回注不带边界直接拼进 system | 记忆内容被当指令执行,注入面扩大 | <memory> XML 边界 + Instructions 声明按 data 处理 |
| 用占位截断文本冒充模型摘要 | evidence 把拼接字符串当模型能力引用 | 占位摘要自带 [占位摘要: 确定性截断拼接, 未经模型总结] 标记;live 摘要标 unverified-live |
规范与延伸
- OpenAI Structured Outputs 文档(strict schema 限制以执行日官方文档为准)。
- OpenAI Prompt caching:前缀稳定是命中前提。
- MemGPT: Towards LLMs as Operating Systems(Packer 等,2023)。
- Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory(ADD/UPDATE/DELETE/NOOP 写入路径)。
- CoALA: Cognitive Architectures for Language Agents(semantic/episodic/procedural 分类)。
- 诚实声明:A2 冲突实验与 A3 三档对照的 live 行为、model-graded 断言、真实模型摘要,在本仓库证据中一律
unverified-live;离线证据只覆盖 prompt 契约、确定性输出断言与记忆层逻辑。
前端/Agent 迁移
你写过的表单校验就是确定性断言的同构物:schema 先行、错误即红。prompt caching 等价于你对 HTTP 缓存的直觉——前缀不变才命中,所以易变部分沉底。XML 边界对应前端转义/textContent 纪律:数据与指令分层。记忆四模式你全见过:滑动窗口是环形 buffer,摘要是 lossy 压缩,记忆 RAG 是带召回的缓存,画像是单文档 JSON store。真正新的只有两点:约束的"执行者"是采样的模型而不是确定性代码,所以断言要放在输出侧兜底;记忆写入需要冲突决策,因为事实会过期。
口述与自测(不看资料,5–10 分钟)
Prompt 工程:
- 口述四段式 system prompt 各段职责;为什么 Context 放尾部?角色层级与 XML 边界为什么是缓解不是防御?
- JSON mode 与 strict json_schema 的保证差异是什么?strict schema 的三条限制(
additionalProperties、required 全声明、nullable 表可选)各解决什么? - 线上格式约束失效,你的排查顺序是什么?(是否根本没开约束 → 是否只开了 JSON mode → 指令与示例是否冲突 → 多轮格式漂移 → 输出侧断言兜底)
- prompt 变更如何防回归?说出 golden dataset、确定性断言与 model-graded 断言的分工,以及 CI gate 卡什么。
- few-shot 示例怎么选?指令与示例冲突时模型往往跟谁,为什么?
记忆:
- 对话超窗的四种处理模式各是什么机制、在什么场景失效?
- 跨会话记忆怎么设计?事实从抽取到回注经过哪几步,哪一步最可能污染上下文?
- 用户说"我搬家了",记忆系统应该怎么写?为什么 append-only 是错的?
- 用 OS 虚拟内存类比讲 MemGPT:main context、recall/archival、paging 各对应什么?
- 记忆污染有哪些缓解手段?(召回阈值、来源标记、XML 边界按 data 处理、冲突决策、定期清理)
动手实验
把"prompt 即代码"与"记忆有写入语义"钉成离线确定性证据:prompt 契约断言、5 条 golden 输出断言、tool 配对截断、摘要占位诚实标记、事实冲突决策序列全部由测试覆盖;live 模型行为只在你自己的 key 下单独核验并标记。
环境准备
bash
cd <仓库根>
export PYTHONPATH="$PWD/python"命令与预期输出
bash
# 1) 离线确定性测试(CI 默认线,无需 key、无网络)
.venv/bin/python -m pytest python/tests/test_memory.py -q
# 2) 记忆层场景演示:截断配对 / 冲突决策 / 检索回注 / 占位摘要
.venv/bin/python - <<'PY'
from agent_core.memory import FactStore, SummaryMemory, trim_to_token_budget, message_tokens
history = [{"role": "system", "content": "你是记账助手"}]
history.append({"role": "user", "content": "q0 " + "x" * 36})
history.append({"role": "assistant", "content": "", "tool_calls": [{"id": "call_1", "name": "lookup", "arguments": {"q": "x"}}]})
history.append({"role": "tool", "tool_call_id": "call_1", "content": "r1 " + "y" * 36})
history.append({"role": "user", "content": "q1 " + "x" * 36})
kept = trim_to_token_budget(history, 14 + message_tokens(history[3]) + message_tokens(history[4]) + 1)
print("trim_in:", len(history), "trim_out:", len(kept), "roles:", [m["role"] for m in kept])
store = FactStore()
store.ingest("我的地址是杭州市西湖区")
store.ingest("我的地址是杭州市西湖区")
store.ingest("后来搬家了, 我把地址改成上海市浦东新区")
store.ingest("请删除我的地址")
print("decisions:", [d for d, _ in store.history])
store2 = FactStore()
store2.ingest("我的地址是杭州市西湖区")
print("reinject:", repr(store2.render_for_prompt("收货地址填哪里")))
mem = SummaryMemory(recent=2)
msgs = [{"role": "user", "content": f"old {i}"} for i in range(4)] + [{"role": "user", "content": "new 0"}, {"role": "user", "content": "new 1"}]
out = mem.compress(msgs)
print("summary_first_line:", out[0]["content"].splitlines()[0])
print("summary_kept:", [m["content"] for m in out[1:]])
PYtext
.................... [100%]
20 passed in 0.13s
trim_in: 5 trim_out: 2 roles: ['system', 'user']
decisions: ['ADD', 'NOOP', 'UPDATE', 'DELETE']
reinject: '<memory>\n- 地址: 杭州市西湖区\n</memory>'
summary_first_line: [占位摘要: 确定性截断拼接, 未经模型总结]
summary_kept: ['new 0', 'new 1']
判定信号:
截断只丢整条 tool unit,输出里没有任何 stranded tool result
改地址冲突走出 ADD → NOOP → UPDATE → DELETE,每 key 唯一当前值
检索回注带 <memory> XML 边界;零重叠查询返回空串
占位摘要自带诚实标记,绝不会被误引用为模型摘要
golden 5 条全过;删掉 JSON 指令后契约断言恰好报 missing JSON-output instructionlive 核验(有 key 时自选,不计入本仓库证据):用第14章的客户端对 A2 冲突 prompt 与 A3 三档 response_format 各发一次请求,对照本节描述的行为,把结果记进你自己的学习者证据。
概念图
资源 / 成本 / 隐私
离线路径(测试与场景演示)gross cost 为 0、无网络。live 路径走第14章同一免费层与同一纪律:key 只走环境变量;用户私有信息(地址、预算等)属于 Context/记忆内容,发往 provider 前按第14章的数据边界声明自行判断。摘要记忆的默认实现是确定性占位,不调用任何模型。
Evidence
仓库当前机器证据(只读快照)
evidence/15-prompt-memory-v1.json 是当前 checkout 的脱敏机器运行记录,只覆盖离线路径:test_memory.py 20 项全部通过、5 条 golden prompt 回归、事实冲突决策序列 [ADD, NOOP, UPDATE, DELETE]、截断配对不变量。live 模型路径(指令-示例冲突实验、三档 response_format 对照、模型摘要、model-graded 断言)在无 key 环境下标记 unverified-live 并写入 known_failures。本模块已登记进 evidence/module-manifest-v1.json。
学习者提交模板(待填写,不是当前机器证据)
复制下面模板并填写自己的真实运行结果。所有 <...> 都是未填写状态;actual 和 artifacts 尤其不能被当作已运行或已通过。artifacts 必须替换为本次提交中真实存在的仓库相对路径。live 指标(若有)必须来自你自己的 key 的真实调用,并在 known_failures 注明数据边界。
yaml
schema: learn-llm.evidence.v1
module: 15-prompt-memory
commit: <learner-commit-sha>
verified_at: <iso-date>
environment: <sanitized-python-device>
seed: 10
commands:
- PYTHONPATH=python python -m pytest python/tests/test_memory.py -q
metrics:
- name: offline_tests_passed
expected: 20
actual: <recorded-value>
- name: golden_prompt_cases_passed
expected: 5
actual: <recorded-value>
- name: fact_conflict_decision_sequence
expected: [ADD, NOOP, UPDATE, DELETE]
actual: <recorded-value>
- name: live_structured_outputs_comparison
expected: unverified-live
actual: <recorded-value-or-unverified-live>
artifacts:
- <learner-repo-relative-artifact-path>
cost:
gross_usd: 0
credit_usd: 0
licenses:
- source: <source>
version: <version>
license: <license>
attribution: <attribution>
redistribution: <redistribution>
known_failures:
- <sanitized-failure-or-none>只有 live 调用截图、没有 golden 回归与记忆冲突决策断言时,本章保持 gate。
下一步
- 第16章 · (页面由后续工作流创建):把手写 loop 映射到 LangGraph——StateGraph、checkpointer、
interrupt式 HITL 与五类 workflow 模式。