第四章:Prompt 与 Context 工程
Prompt 工程和 Context 工程不是两件独立的事。它们解决的是同一个问题的两面:
- Prompt 工程回答"怎么写指令,让模型回答得更准"。
- Context 工程回答"怎么管理上下文窗口,让模型看到该看的、不看不该看的"。
两者的交集很大。System Prompt 是 context 的一部分;few-shot 示例占 context 空间;Chain-of-Thought 的推理过程也消耗 token 预算。所以这一章把它们放在一起。
4.1 Prompt Engineering
4.1.1 System Prompt 设计
System Prompt 是你对模型的"入职培训"。好的 System Prompt 做四件事:
- 角色——你是谁
- 边界——什么不能做
- 格式约束——输出长什么样
- 领域知识——关键业务规则
旅游助手的 System Prompt:
SYSTEM_PROMPT = """你是杭州旅游助手。
## 角色
- 只回答杭州相关的旅游问题
- 不编造实时信息(天气、票价、营业时间),没有工具返回就说"我不确定"
## 边界
- 不回答医疗、法律、金融建议
- 不帮用户做任何预订操作(只提建议)
- 如果用户问题不属于旅游范围,直接拒绝
## 输出格式
- 行程用 JSON 返回,schema: {"spots": [{"name": str, "time": str, "note": str}]}
- 普通对话用纯文本
## 领域知识
- 西湖景区步行一圈约 10km,建议骑车或分段
- 龙井茶最佳体验在梅家坞和龙井村
- 河坊街小吃集中但节假日极度拥挤
"""
逐行解释:
你是杭州旅游助手—— 角色声明,一句话。不要写"你是一个专业的、友好的、有帮助的旅游助手"这种堆叠形容词的废话。不编造实时信息—— 这条边界直接防止幻觉最严重的场景。模型最容易在天气、票价这些实时数据上胡说。不回答医疗、法律、金融建议—— 画出能力边界。没有这条,用户问"杭州哪家医院看骨科好",模型会硬答。输出格式—— 后续代码要 parse JSON,这里把 schema 写死。领域知识—— 三条硬编码的事实。模型不一定知道西湖一圈 10km,但你知道。直接写进去比指望模型自己猜靠谱。
System Prompt 的几个原则:
| 原则 | 做法 | 反例 |
|---|---|---|
| 具体 > 模糊 | "只回答杭州旅游" | "尽量回答旅游相关的问题" |
| 禁止 > 建议 | "不编造实时信息" | "尽量避免编造" |
| 格式写死 | 给出 JSON schema | "请用结构化格式返回" |
| 放关键事实 | "西湖一圈约10km" | 什么都不放,指望模型知道 |
4.1.2 Few-shot Prompting
Few-shot 就是在 prompt 里放几个"示范问答",让模型照着格式和逻辑走。
没有 few-shot 时:
from agent_patterns_lab.runtime.mock_model import MockLLM
from agent_patterns_lab.runtime.types import Message
model_no_fewshot = MockLLM([
'{"spots": [{"name": "西湖", "time": "上午", "note": "骑车环湖"}], "transport": "公交"}'
])
messages_no_fewshot = [
Message(role="system", content="你是杭州旅游助手。返回 JSON,schema: {\"spots\": [{\"name\": str, \"time\": str, \"note\": str}]}"),
Message(role="user", content="我喜欢喝茶,安排一天杭州行程"),
]
result_no_fewshot = model_no_fewshot.complete(messages_no_fewshot)
print(result_no_fewshot)
输出:
{"spots": [{"name": "西湖", "time": "上午", "note": "骑车环湖"}], "transport": "公交"}
问题在哪?模型返回了一个 transport 字段,但你的 schema 里没有它。没有示范,模型对格式的理解全靠猜。
有 few-shot 时:
model_fewshot = MockLLM([
'{"spots": [{"name": "梅家坞", "time": "上午", "note": "品龙井茶"}, {"name": "西湖", "time": "下午", "note": "断桥残雪方向步行"}]}'
])
fewshot_examples = [
Message(role="user", content="我喜欢拍照,安排半天杭州行程"),
Message(role="assistant", content='{"spots": [{"name": "西湖·苏堤", "time": "上午", "note": "日出最佳拍摄点"}, {"name": "雷峰塔", "time": "中午前", "note": "俯拍西湖全景"}]}'),
]
messages_fewshot = [
Message(role="system", content="你是杭州旅游助手。返回 JSON,schema: {\"spots\": [{\"name\": str, \"time\": str, \"note\": str}]}"),
*fewshot_examples,
Message(role="user", content="我喜欢喝茶,安排一天杭州行程"),
]
result_fewshot = model_fewshot.complete(messages_fewshot)
print(result_fewshot)
输出:
{"spots": [{"name": "梅家坞", "time": "上午", "note": "品龙井茶"}, {"name": "西湖", "time": "下午", "note": "断桥残雪方向步行"}]}
格式完全匹配 schema,没有多余字段。
Few-shot 示例的选择策略:
| 策略 | 适用场景 | 代价 |
|---|---|---|
| 固定示例 | 格式不变、任务类型单一 | 占固定 token |
| 按类型选择 | 用户输入先分类,再选对应类型的示例 | 需要路由逻辑 |
| 语义检索 | 大量示例库,embedding 检索最相似的 | 需要向量检索基础设施 |
多数场景用固定示例就够了。示例数量 2-5 个是甜区——太少不足以约束格式,太多浪费 token。
4.1.3 Chain-of-Thought
Chain-of-Thought(CoT)的核心思想:让模型在给出最终答案之前,先显式地写出推理步骤。
最简单的触发方式是在 prompt 末尾加一句 Let's think step by step。但在工程实践中,结构化的 CoT 更可控。
COT_SYSTEM = """你是杭州旅游助手。
回答问题时,先列出推理步骤,再给出结论。格式:
## 推理
1. [步骤1]
2. [步骤2]
...
## 结论
[最终回答]
"""
model_cot = MockLLM([
"""## 推理
1. 用户想一天内玩杭州,喜欢茶
2. 杭州最著名的茶体验在梅家坞和龙井村
3. 上午去梅家坞品茶,路程约30分钟
4. 下午安排西湖步行,从断桥到苏堤约3km,步行1小时
5. 傍晚去河坊街吃小吃,距西湖约2km
## 结论
上午:梅家坞品茶(约2小时)
下午:西湖断桥→苏堤步行(约2小时)
傍晚:河坊街小吃(约1.5小时)"""
])
messages_cot = [
Message(role="system", content=COT_SYSTEM),
Message(role="user", content="我喜欢喝茶,安排一天杭州行程"),
]
result_cot = model_cot.complete(messages_cot)
print(result_cot)
输出:
## 推理
1. 用户想一天内玩杭州,喜欢茶
2. 杭州最著名的茶体验在梅家坞和龙井村
3. 上午去梅家坞品茶,路程约30分钟
4. 下午安排西湖步行,从断桥到苏堤约3km,步行1小时
5. 傍晚去河坊街吃小吃,距西湖约2km
## 结论
上午:梅家坞品茶(约2小时)
下午:西湖断桥→苏堤步行(约2小时)
傍晚:河坊街小吃(约1.5小时)
CoT 和 ReAct 的关系:
| CoT | ReAct | |
|---|---|---|
| 推理 | 在一次回答内写出步骤 | 每轮只写一步 Thought |
| 行动 | 无——纯推理,没有工具调用 | 每轮可以调用工具 |
| 观察 | 无——没有外部信息注入 | 工具返回的结果 |
| 适用 | 复杂逻辑推理、数学、规划 | 需要实时信息、多步工具交互 |
CoT 是 ReAct 的"只推理"版本。当任务不需要外部工具(不需要查天气、查路线),纯 CoT 就够了。当任务需要外部事实,CoT 的推理就容易建立在幻觉之上——这时候需要 ReAct 把推理拆成多轮,每轮用工具验证。
4.2 Context Engineering
4.2.1 Context 过载
Context window 是有限的。GPT-4o 是 128k token,Claude 是 200k token,但"能放进去"和"放进去有用"是两回事。
旅游助手的 context 过载场景:
System Prompt: ~500 token
Few-shot 示例(3个): ~600 token
对话历史(20轮): ~4000 token
工具定义(15个工具): ~3000 token
工具返回结果(5次): ~2500 token
────────────────────────────
总计: ~10600 token
10600 token 看起来不多。但问题不是总量——是模型对 context 中信息的利用效率。实验反复证明:
- 中间丢失——模型更关注 context 开头和结尾的内容,中间段落的信息容易被忽略。
- 工具过多——15 个工具描述放在 system prompt 里,模型选错工具的概率上升。不是因为模型笨,是因为描述之间互相干扰。
- 历史冗余——20 轮对话里可能有 15 轮已经无关,但它们占着 token 预算。
4.2.2 Context 策略
四种策略,每种解决不同的过载问题。
策略一:Progressive Disclosure(渐进暴露)
只在需要时才把信息放进 context。
import json
def build_tool_definitions(stage: str) -> list[dict]:
"""根据当前阶段只返回相关工具定义。"""
all_tools = {
"planning": [
{"name": "get_weather", "description": "查询城市天气"},
{"name": "search_places", "description": "根据兴趣搜索景点"},
],
"booking": [
{"name": "check_availability", "description": "查询景点可预约时段"},
{"name": "create_booking", "description": "创建预约"},
],
"general": [
{"name": "get_weather", "description": "查询城市天气"},
],
}
return all_tools.get(stage, all_tools["general"])
# 规划阶段:只给 2 个工具
planning_tools = build_tool_definitions("planning")
print(f"规划阶段工具数: {len(planning_tools)}")
print(json.dumps(planning_tools, ensure_ascii=False, indent=2))
# 预订阶段:换成 2 个预订工具
booking_tools = build_tool_definitions("booking")
print(f"预订阶段工具数: {len(booking_tools)}")
print(json.dumps(booking_tools, ensure_ascii=False, indent=2))
输出:
规划阶段工具数: 2
[
{"name": "get_weather", "description": "查询城市天气"},
{"name": "search_places", "description": "根据兴趣搜索景点"}
]
预订阶段工具数: 2
[
{"name": "check_availability", "description": "查询景点可预约时段"},
{"name": "create_booking", "description": "创建预约"}
]
每个阶段模型只看到 2 个工具,不是 4 个。选错的概率直接减半。
策略二:Offload(卸载)
把不需要实时存在于 context 的信息存到外部,用到时再取。
from agent_patterns_lab.runtime.memory.kv import KVMemory
memory = KVMemory()
# 用户偏好不需要每轮都放在 messages 里
memory.set("user_preferences", "喜欢喝茶、走路不想太累、预算 500 以内")
memory.set("visited_places", "西湖、灵隐寺")
# 需要时取出,拼进当前轮的 prompt
prefs = memory.get("user_preferences")
visited = memory.get("visited_places")
context_injection = f"用户偏好: {prefs}\n已去过: {visited}"
print(context_injection)
输出:
用户偏好: 喜欢喝茶、走路不想太累、预算 500 以内
已去过: 西湖、灵隐寺
这样,用户偏好不占常驻 context 空间。只有在模型真正需要参考偏好时,才注入当前轮。
策略三:Cache(缓存)
同一个工具调用的结果,在短时间内不会变。天气查了一次,5 分钟内不需要再查。
from agent_patterns_lab.runtime.cache import InMemoryCache, cached
cache: InMemoryCache[str] = InMemoryCache()
call_count = 0
def expensive_weather_call() -> str:
global call_count
call_count += 1
return '{"forecast": "多云转晴", "temperature_c": "22-28"}'
# 第一次:cache miss,调用函数
result1 = cached(cache, key="weather:hangzhou:today", compute=expensive_weather_call, ttl_s=300)
print(f"第1次结果: {result1}, 实际调用次数: {call_count}")
# 第二次:cache hit,不调用函数
result2 = cached(cache, key="weather:hangzhou:today", compute=expensive_weather_call, ttl_s=300)
print(f"第2次结果: {result2}, 实际调用次数: {call_count}")
输出:
第1次结果: {"forecast": "多云转晴", "temperature_c": "22-28"}, 实际调用次数: 1
第2次结果: {"forecast": "多云转晴", "temperature_c": "22-28"}, 实际调用次数: 1
函数只调用了 1 次。第二次直接从缓存取。这既省了 API 调用成本,也减少了工具结果重复塞进 context 的浪费。
策略四:Isolate(隔离)
不同子任务用不同的 context。一个子任务的对话历史不应该污染另一个子任务。
from agent_patterns_lab.runtime.types import Message
def make_subtask_context(
system: str,
task: str,
relevant_history: list[Message] | None = None,
) -> list[Message]:
"""为子任务构建独立 context,只包含相关历史。"""
messages = [Message(role="system", content=system)]
if relevant_history:
messages.extend(relevant_history)
messages.append(Message(role="user", content=task))
return messages
# 天气子任务:不需要之前关于"用户喜欢喝茶"的对话历史
weather_context = make_subtask_context(
system="你是天气查询助手。只回答天气相关问题。",
task="明天杭州天气如何?",
)
# 行程子任务:需要天气结果,但不需要天气查询的中间对话
itinerary_context = make_subtask_context(
system="你是行程规划助手。根据天气和偏好规划行程。",
task="根据以下信息规划行程:天气多云转晴,22-28°C,用户喜欢喝茶",
)
print(f"天气子任务 context: {len(weather_context)} 条消息")
print(f"行程子任务 context: {len(itinerary_context)} 条消息")
输出:
天气子任务 context: 2 条消息
行程子任务 context: 2 条消息
每个子任务只有 2 条消息(system + user),干净得很。不会因为上一个子任务的 20 轮对话而干扰当前判断。
4.2.3 Token 预算管理
Token 有三种消耗方向:
| 类型 | 你能控制什么 | 典型占比 |
|---|---|---|
| 输入 token | messages 总长度 | 60-80% |
| 输出 token | max_tokens 参数 | 15-30% |
| 缓存 token | 是否命中缓存前缀 | 取决于 provider |
一个真实的预算分配计算:
def calculate_token_budget(
total_budget: int = 8000,
system_prompt_tokens: int = 500,
fewshot_tokens: int = 600,
max_output_tokens: int = 1500,
) -> dict:
"""计算各部分的 token 预算。"""
fixed_cost = system_prompt_tokens + fewshot_tokens
available_for_dynamic = total_budget - fixed_cost - max_output_tokens
budget = {
"total": total_budget,
"system_prompt": system_prompt_tokens,
"fewshot": fewshot_tokens,
"max_output": max_output_tokens,
"fixed_total": fixed_cost,
"available_for_history_and_tools": available_for_dynamic,
}
# 动态部分再细分
budget["history_budget"] = int(available_for_dynamic * 0.6)
budget["tool_results_budget"] = int(available_for_dynamic * 0.4)
return budget
budget = calculate_token_budget()
for k, v in budget.items():
print(f"{k}: {v}")
输出:
total: 8000
system_prompt: 500
fewshot: 600
max_output: 1500
fixed_total: 1100
available_for_history_and_tools: 5400
history_budget: 3240
tool_results_budget: 2160
解读这个预算:
- 固定成本 1100 token——system prompt + few-shot 示例,每次请求都要带。
- 输出预留 1500 token——模型回答的空间。
- 动态空间 5400 token——对话历史和工具返回结果共用。
- 历史 3240 token——大约能放 16 轮短对话(每轮 ~200 token)。
- 工具结果 2160 token——大约能放 4-5 次工具返回。
如果 ReAct 循环跑了 8 轮,每轮工具返回 500 token,那工具结果就是 4000 token——超出预算。这时候需要:
- 对旧的工具返回做摘要压缩
- 只保留最近 N 轮的完整结果,更早的只保留关键字段
- 或者直接限制
max_steps,从根源上控制轮数
def truncate_tool_results(
messages: list[Message],
max_tool_tokens: int = 2000,
chars_per_token: float = 2.5, # 中文大约 1 token ≈ 2-3 个字符
) -> list[Message]:
"""超出 token 预算时,截断较早的工具返回。"""
max_chars = int(max_tool_tokens * chars_per_token)
tool_messages = [(i, m) for i, m in enumerate(messages) if m.role == "tool"]
total_chars = sum(len(m.content) for _, m in tool_messages)
if total_chars <= max_chars:
return messages
# 从最早的工具消息开始截断
result = list(messages)
for idx, msg in tool_messages:
if total_chars <= max_chars:
break
original_len = len(msg.content)
# 保留前 200 字符 + 截断标记
truncated = msg.content[:200] + "\n...[已截断,完整结果已归档]"
result[idx] = Message(role="tool", name=msg.name, content=truncated)
total_chars -= (original_len - len(truncated))
return result
# 演示
sample_messages = [
Message(role="system", content="你是旅游助手"),
Message(role="user", content="杭州天气"),
Message(role="tool", name="get_weather", content="x" * 3000), # 模拟超长工具返回
Message(role="tool", name="search_places", content="y" * 3000),
]
truncated = truncate_tool_results(sample_messages, max_tool_tokens=2000)
print(f"截断前工具消息总长: {sum(len(m.content) for m in sample_messages if m.role == 'tool')}")
print(f"截断后工具消息总长: {sum(len(m.content) for m in truncated if m.role == 'tool')}")
输出:
截断前工具消息总长: 6000
截断后工具消息总长: 3448
本章回顾
| 问题 | 解法 | 关键代码 |
|---|---|---|
| 模型不守格式 | Few-shot 示例 | 在 messages 里加示范 QA 对 |
| 推理过程不透明 | Chain-of-Thought | System Prompt 要求先推理再结论 |
| 工具太多选错 | Progressive Disclosure | 按阶段只暴露相关工具 |
| 历史过长 | Offload + 截断 | KV 存偏好;超预算截断旧工具返回 |
| 重复查询 | Cache | InMemoryCache + TTL |
| 子任务互相干扰 | Isolate | 每个子任务构建独立 context |
| Token 爆炸 | 预算管理 | 固定 + 动态 + 输出分别计算 |
Prompt 和 Context 工程是所有后续模式的基础。ReAct 循环的每一轮都在做 context 管理——把新的 observation 塞进 messages,同时要保证不超预算。
附录: 附录 4A:Prompt Caching 详细介绍 OpenAI、Anthropic、DeepSeek 三家的缓存机制和成本节约计算。
下一章: 第五章:ReAct —— 当一次调用不够时,让模型循环地推理和行动。