跳转至

第四章: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 做四件事:

  1. 角色——你是谁
  2. 边界——什么不能做
  3. 格式约束——输出长什么样
  4. 领域知识——关键业务规则

旅游助手的 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 中信息的利用效率。实验反复证明:

  1. 中间丢失——模型更关注 context 开头和结尾的内容,中间段落的信息容易被忽略。
  2. 工具过多——15 个工具描述放在 system prompt 里,模型选错工具的概率上升。不是因为模型笨,是因为描述之间互相干扰。
  3. 历史冗余——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——超出预算。这时候需要:

  1. 对旧的工具返回做摘要压缩
  2. 只保留最近 N 轮的完整结果,更早的只保留关键字段
  3. 或者直接限制 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 —— 当一次调用不够时,让模型循环地推理和行动。