Day 18 / 共 68 天 · 阶段 3 提示词工程
上下文工程
昨天(Day 17)你学会了用进阶技巧把单条 prompt 写得又准又稳。但真实应用里,你要塞给模型的不只一句话——还有对话历史、检索到的资料、工具结果……而模型的上下文窗口有限且花钱。今天学 上下文工程:给窗口做预算、只放相关信息、压缩历史、避开"中间被忽略"的坑。这是让 Agent 又准又省的关键内功;明天(Day 19)学怎么把 prompt 当代码一样版本管理和 A/B。
📍 你在阶段 3(提示词工程 D16-20)的位置
D16 Prompt 基础→
D17 进阶 Prompt→
D18 上下文工程→
D19 版本管理&A/B→
D20 注入防护
💡 用一套类比兜住今天(今天全程沿用「一张有限的办公桌桌面」的世界观)
模型的上下文窗口 = 一张大小固定的办公桌桌面:只有摆在桌面上的资料,模型这一次才"看得见";桌子就这么大,摆不下就得取舍。上下文预算 = 桌面每块区域分配给谁;只放相关信息 = 别把无关文件全堆上来挡视线;历史压缩 = 把厚厚的会议记录浓缩成一页纪要;lost in the middle = 压在一摞纸中间的那页最容易被忽略。今天你学会当"桌面整理师"。
L01
上下文窗口:一张有限的桌面
🤔 痛点你以为把资料一股脑全喂给模型它就都能用?结果要么报错"超出长度",要么账单暴涨,要么它偏偏漏掉了你最看重的那句。
💡 本质模型一次能"看见"的内容有上限,这个上限叫上下文窗口(context window),以 token 计(回忆 Day10/13)。窗口就是那张固定大小的桌面:system 提示、对话历史、检索资料、当前问题、以及它要生成的回答,全都得挤在这张桌面上。
👶 窗口有多大?不同模型不一样,从几千到几十万甚至上百万 token 都有。但"窗口大"不等于"随便塞"——塞得越多,一是越贵(每个 token 都计费),二是越慢,三是模型越容易抓不住重点(下面 L05 会讲)。大窗口是余地,不是偷懒的借口。
📝 举个例子:桌面被挤爆的后果
做一个"基于公司文档答疑"的助手:如果把整本 300 页手册全塞进去,① 可能直接超窗口报错;② 就算塞得下,一次问答几毛钱、几十万 token;③ 答案里真正相关的可能就一段,其余全是干扰。正确做法是只检索出相关的那几段放上桌(这正是 Day21 起 RAG 要解决的)。
L02
上下文预算:给桌面各区分配空间
🤔 痛点桌面就这么大,system 提示、历史、资料、用户问题都想多占点——到底谁该占多少?没个章法就会乱。
💡 本质上下文预算:像做家庭开支一样,事先规划"桌面各区域各分多少 token",并给模型的回答留足空间。总预算 = 输入(system+历史+资料+问题) + 输出(回答),两头都要算进去。
图注:新手最常忘的是"给回答留空间"——输入塞太满,模型没地方写完整答案就被截断。
👶 怎么估 token 数?粗略记:中文大约 1 个字 ≈ 1~2 token,英文大约 4 个字符 ≈ 1 token。要精确可用各家的 tokenizer 工具(如 OpenAI 的 tiktoken 库)。实战里不用太精确,有"这段大概多少 token、别超预算"的意识就够了。
L03
只放相关信息:别拿无关文件挡视线
🤔 痛点"多给点信息总没错吧?"——恰恰相反。塞一堆无关内容,不仅浪费预算,还会把模型带偏,让它被噪音干扰、答非所问。
💡 本质上下文的黄金律:放"相关且必要"的,而不是"所有能放的"。就像整理桌面——只留手头这件事要用的资料,其余收进抽屉。信息不是越多越好,是越准越好。
👶 小白:可我怎么知道哪些信息"相关"?总不能每次人工挑吧。
👨🏫 老师:正中要害!这就是整个 RAG(检索增强生成) 要解决的核心问题——用"检索"自动从海量资料里只捞出跟当前问题最相关的几段放进上下文。你从 Day21 开始会专门学它(embedding、向量库、检索、rerank 一整套)。今天先建立意识:"精准喂料"比"海量喂料"重要得多,这是 RAG 存在的根本理由。
📝 举个例子:相关性 > 数量
用户问"退货政策是几天"。与其把整份 50 页用户协议塞进去,不如只放"退货条款"那一段。前者又贵又容易让模型在无关条款里绕晕;后者又便宜、答案又准。少即是多。
L04
历史压缩:把会议记录缩成一页纪要
🤔 痛点回忆 Day11:多轮对话要把全部历史一起发过去。聊得越久,历史越长,最后桌面全被旧对话占满——又贵又慢,还挤掉了新信息的空间。
💡 本质历史压缩/摘要:当对话变长,把较早的历史浓缩成一段摘要(记住关键事实/结论),只保留最近几轮的原话。就像把厚厚的会议记录缩成一页纪要——要点还在,篇幅小了。
# 简化示意:历史太长时,让模型自己把"旧历史"总结成一段摘要
def compress(history, keep_recent=4):
if len(history) <= keep_recent + 2:
return history # 还不长,不用压
old = history[1:-keep_recent] # 除了 system 和最近几轮,都算"旧的"
text = "\n".join(f"{m['role']}: {m['content']}" for m in old)
summary = client.chat.completions.create( # 让模型把旧对话总结成要点
model="gpt-4o-mini",
messages=[{"role": "user", "content": "把以下对话压成要点摘要:\n" + text}]
).choices[0].message.content
# 重组:system + 一条摘要 + 最近几轮原话
return [history[0],
{"role": "system", "content": "早前对话摘要:" + summary}
] + history[-keep_recent:]
👶 会丢信息吗?会——压缩必然有损。所以要点是保留"对后续有用的关键事实"(用户叫什么、已确认的需求、已排除的选项),丢掉寒暄和绕路。这也是"短期记忆"的雏形;等学到 Agent 记忆(Day36)会看到更系统的做法:短期放对话、长期存进向量库(Day23 起)。
L05
lost in the middle:中间的最容易被忽略
🤔 痛点你明明把关键信息放进上下文了,模型却"视而不见"、答漏了它。信息在里面,为什么它没用上?
💡 本质有一个反直觉现象叫 "lost in the middle"(迷失在中间):当上下文很长时,模型对开头和结尾的信息记得最牢,压在中间的信息最容易被忽略。就像一摞文件,你最容易注意到最上面和最下面那张,中间的常被略过。
图注:把最关键的指令/信息放在上下文的开头或结尾,别埋在长长的中段。
📝 举个例子:怎么利用这个规律
给模型 10 段检索资料时,别把最相关的那段随手放中间。实战技巧:把最重要的信息放在开头或结尾;关键指令(如"务必引用来源")可以在结尾再重申一遍。RAG 里(Day26 rerank)也常把最相关的文档排到两端。位置也是一种"喂料"手段。
L06
组装上下文:一次调用怎么拼
🤔 痛点道理都懂了,实战里"发给模型的那个 messages 列表"到底该按什么顺序、放哪些东西拼出来?
💡 本质把 messages 想成"给桌面分区摆放":system(人设+规则)开头 → 相关资料/摘要 → 最近历史 → 当前问题结尾,关键指令首尾各强调一次。下面是一个组装函数的骨架。
def build_messages(system_prompt, retrieved_docs, history, user_question):
msgs = [{"role": "system", "content": system_prompt}] # ① 人设放最前
if retrieved_docs: # ② 相关资料(只放相关的!)
docs = "\n---\n".join(retrieved_docs)
msgs.append({"role": "system", "content": f"参考资料:\n{docs}"})
msgs += history[-6:] # ③ 只留最近几轮(更早的应已压缩)
msgs.append({"role": "user", "content": # ④ 当前问题放最后(结尾=高注意力)
f"{user_question}\n\n请只依据上面的参考资料回答。"}) # 关键指令在结尾重申
return msgs
# 之后照常 client.chat.completions.create(model=..., messages=build_messages(...))
👶 这套骨架你以后天天见几乎所有 RAG 应用、Agent 框架内部,都是在做这件事:把"人设 + 相关知识 + 精简历史 + 当前问题"动态拼成一次调用的上下文。今天手写一遍,等用 LlamaIndex / LangGraph(Day21、Day40)时,你会一眼看穿框架帮你做了什么。
L07
今日小结 + 动手 10 分钟
🧠 今天你应该能回答
- 上下文窗口是什么?为什么"塞得下"不等于"该塞满"?
- 上下文预算要把哪些部分都算进去?(别忘了给回答留空间)
- 为什么"只放相关信息"比"多放信息"更好?这引出了后面哪门技术?(RAG)
- 对话变长了怎么办?历史压缩会牺牲什么、要保住什么?
- "lost in the middle"是什么现象?据此该把关键信息放哪?
✋ 动手 10 分钟:给聊天机器人加"历史压缩"
在 Day11/12 的环境里,给多轮机器人加一个"历史过长就摘要"的机制,直观感受上下文管理:
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI()
history = [{"role": "system", "content": "你是助手,回答简洁"}]
def summarize(msgs):
text = "\n".join(f"{m['role']}: {m['content']}" for m in msgs)
r = client.chat.completions.create(model="gpt-4o-mini",
messages=[{"role": "user", "content": "用要点压缩以下对话:\n" + text}])
return r.choices[0].message.content
while True:
q = input("\n你:")
if q == "quit": break
history.append({"role": "user", "content": q})
# 简单预算:超过 10 条就把中间压成摘要,只留 system + 摘要 + 最近 4 条
if len(history) > 10:
s = summarize(history[1:-4])
history[:] = [history[0], {"role":"system","content":"早前摘要:"+s}] + history[-4:]
print("(已压缩历史,节省上下文)")
r = client.chat.completions.create(model="gpt-4o-mini", messages=history)
a = r.choices[0].message.content
history.append({"role": "assistant", "content": a})
print("AI:", a, f" [当前历史 {len(history)} 条]")
多聊几轮,观察历史条数在触发压缩后如何被"缩回去",机器人却还记得关键信息。
明日预告 · Day 19:prompt 写好了,可它其实是你产品里最爱变、最难管的一段"文字代码"。明天学 Prompt 版本管理 & A/B:把 prompt 从代码里抽出来存进文件、加版本号,用小样本对比"改了之后到底变好还是变坏"。这是从"凭感觉调 prompt"走向"用数据说话"的第一步,也为后面的评测(Day49)埋下伏笔。