Python 智能体开发实战 02:你的 AI 助教说话像换了个模型?九成的 system prompt 都写错了

0 阅读11分钟

提示词怎么决定回答

上周有个学员找我,说他的英语批改助手"抽风":同一段代码,上午批改返回三行规整的"错在哪—为什么—怎么改",下午就变成一篇热情洋溢的小作文,晚上干脆给他打了个分还附赠评语。

代码一行没动。他怀疑是模型坏了,一度想写个定时重启脚本来"修"它。

我看了他的代码就明白了:system prompt 是一句"你是一个英语老师,帮助学生批改作文"。这一句话,等于什么都没说。模型每次自由发挥,风格当然像抽签。

这一篇我们就把这件事彻底讲透:提示词到底是怎么决定回答的,以及怎么把它变成一个可测试、可版本管理的工程对象,而不是散落在代码里的一句祈祷词。

一、先扫盲:为什么 system prompt 有这么大权力

1. 三种角色,各管各的

上一篇讲过,一次请求里的消息分三种角色:

  • system:定身份和规矩。用户看不见它,但它对模型输出的影响最大。
  • user:用户真正说的话。
  • assistant:模型之前的回答,用于多轮对话。

一个容易被忽略的事实:模型对 system 的服从度远高于 user。 你在 user 消息里说"请用表格回答",模型可能理会也可能不理;同样的要求写进 system,基本每次都执行。所以身份、输出格式、语气这些"每次都该生效"的规矩,全部放 system。

2. 输出不稳定,是因为你只给了目标没给规格

你说"批改这篇作文",模型不知道你要:一句话点评?逐句批注?打分加清单?它只能猜。猜对猜错,全看当天的心情(准确说是采样随机性)。

解决思路有两种,一种贵一种便宜:

  • 贵的思路:让模型输出 JSON,程序解析后强制重渲染。这是下一篇的主题,适合格式必须严格的场景。
  • 便宜的思路:给两三个示例,让模型照着写。 这就是少样本(few-shot),今天讲。

3. temperature:同一个提示词,为什么两次回答不一样

模型生成每个字都是一个概率分布,temperature 控制从分布里怎么挑:

取值行为适合场景
0 ~ 0.3几乎每次挑概率最高的字语法批改、打分、提取信息
0.5 ~ 0.8有一些变化日常对话、陪练
1.0+相当发散头脑风暴、起名

批改助手要的是"同一篇文章每次给出一致的批改",把 temperature 压到 0.2 左右。很多"模型抽风"问题,一半是 prompt 没写清,另一半就是 temperature 没调。

4. 思维链:让模型先打草稿再交卷

模型是逐字生成的。让它直接给结论,相当于默算;让它先写中间步骤,等于把草稿摊在桌面上,每一步都能踩住前一步的正确结果。

对语法批改这种"先找成分、再套规则"的活,思维链提升明显。但它会让输出变长、token 变多,只留给复杂任务,别逢题就挂。

三人格

二、工程设计:把提示词从"字符串"升级成"对象"

第 1 篇的代码里,system prompt 是写死在函数里的字符串。这一篇要改掉这个坏习惯。

新工程结构(settings.py 和 llm.py 与第 1 篇完全一致,原样复用):

02-Prompt工程/
├── src/
│   ├── settings.py   # 复用第 1 篇
│   ├── llm.py        # 复用第 1 篇
│   ├── prompts.py    # 人格字典 + 任务模板
│   ├── fewshot.py    # 少样本示例
│   ├── cot.py        # 思维链三档
│   └── ask.py        # 统一入口
├── tests/            # 27 个用例,离线全绿
└── demo.py

核心思想一句话:人格管"怎么说",任务管"做什么",示例管"输出长什么样",思维链管"解题步骤"——四样东西拆开,才能自由组合。

三、人格与任务:两段 system,分开管(prompts.py)

先看数据:

PERSONAS: dict[str, str] = {
    "strict": """你是「启明英语」的资深语法讲师,以严格著称。
要求:
1. 直接指出错误,不铺垫、不夸奖
2. 每个错误给出:错在哪 → 为什么错 → 正确写法
3. 回答控制在 3 句以内
""",
    "encouraging": """你是「启明英语」的陪伴型助教,面对的是容易灰心的初学者。
要求:
1. 先肯定做对的部分,再指出要改的地方
2. 纠正时给出完整正确句,不要用术语
3. 语气像同班同学,不要用"建议您"这类官话
""",
    "examiner": """你是「启明英语」的阅卷官,按雅思写作评分标准打分。
要求:
1. 先给总分(0-9,可以带 .5),再给四项小分
2. 每项小分后必须跟一句扣分原因
3. 最后给一条最优先的改进建议
""",
}

注意这三段文案的共同点:都有编号要求、都有可验证的格式约束("3 句以内""必须跟一句原因")。 "你是一个专业的英语老师"这种话在要求栏里是零信息量的——专业不专业,模型自己说了不算。

任务模板带变量:

TASK_TEMPLATES: dict[str, str] = {
    "grammar": "请批改下面这句英语的语法,目标对象是 {level} 水平的学生。",
    "polish": "请把下面这句英语改得更自然,目标对象是 {level} 水平的学生。",
    "quiz": "请就下面这个知识点出一道题,难度匹配 {level} 水平。",
}

组装用 LangChain 的 ChatPromptTemplate,这是本篇第一个"用框架"的点:

def build_prompt(
    persona: str = DEFAULT_PERSONA,
    task: str = DEFAULT_TASK,
) -> ChatPromptTemplate:
    """拼出一个完整的对话模板。

    结构是固定的三段:
      system  人格 → 告诉模型"你是谁、怎么说话"
      system  任务 → 告诉模型"这次要做什么"
      human   提问 → 学生真正输入的内容
    """
    return ChatPromptTemplate.from_messages(
        [
            ("system", get_persona(persona)),
            ("system", get_task(task)),
            ("human", "{question}"),
        ]
    )

这里我刻意用了两个 system 而不是合并成一段。合并当然也能跑,但拆开后"换人格不动任务、换任务不动人格",做产品时你会感谢这个决定。

用框架还有个隐藏好处:{level} 这种变量少传了会直接抛 KeyError,而不是静默生成一句带花括号的残缺文本。字符串拼接时代这种事故一查一晚上,框架替你把这类错误挡在了上线前。

四、少样本:把输出规格"演示"一遍(fewshot.py)

ESSAY_EXAMPLES: list[dict[str, str]] = [
    {
        "input": "I go to the park yesterday.",
        "output": (
            "错误:时态\n"
            "原文:I go to the park yesterday.\n"
            "改正:I went to the park yesterday.\n"
            "理由:yesterday 是明确的过去时间,动词要用过去式。"
        ),
    },
    # …… 共 3 条
]

组装时用 FewShotChatMessagePromptTemplate:

EXAMPLE_PROMPT = ChatPromptTemplate.from_messages(
    [
        ("human", "{input}"),
        ("ai", "{output}"),
    ]
)

FEW_SHOT_PROMPT = FewShotChatMessagePromptTemplate(
    example_prompt=EXAMPLE_PROMPT,
    examples=ESSAY_EXAMPLES,
)

def build_fewshot_prompt(system_text: str) -> ChatPromptTemplate:
    """顺序很关键:人格 → 示例 → 真正的提问。"""
    return ChatPromptTemplate.from_messages(
        [
            ("system", system_text),
            FEW_SHOT_PROMPT,
            ("human", "{question}"),
        ]
    )

两个必须记住的坑:

  1. 示例质量决定输出上限。 示例里的 assistant 回答会被当成模板照抄,示例写得敷衍,输出就敷衍。
  2. 提问必须排在最后。 我在代码注释里写了原因:示例放最后,模型会把最后一条示例输出当成"上一轮对话",接着往下续写。

少样本

这张图就是挂了 3 条示例后,真正发给模型的完整消息列表——8 条。你可以打印出来逐条核对,我强烈建议你养成这个习惯:提示词调试的第一步不是盯着回答猜,而是把喂进去的原文看一遍。

五、思维链:三档强度,按需取用(cot.py)

COT_LEVELS: dict[str, str] = {
    "light": "先说结论,再用一句话解释原因。",
    "standard": """按下面三步来:
1. 找出句子里的时间状语和谓语动词
2. 判断时态是否正确
3. 给出改正后的句子
""",
    "heavy": """请严格按下面的步骤拆解,每一步单独一行:
第 1 步 · 成分分析:主语是____,谓语是____,时间状语是____
第 2 步 · 时态判断:这个时间状语要求用____时态
第 3 步 · 问题定位:原文的谓语动词是____,与要求____(一致/不一致)
第 4 步 · 改写句子:____
第 5 步 · 中文解释:____
""",
}

组装时我把思维链挂在 human 消息而不是 system:

def build_cot_prompt(persona: str, cot_level: str = DEFAULT_COT) -> ChatPromptTemplate:
    """思维链引导语放 human 里:system 是长期性格,
    思维链是本次任务的执行步骤,混在一起会让想换人格的人连带改步骤。"""
    return ChatPromptTemplate.from_messages(
        [
            ("system", persona),
            ("human", "{cot}\n\n句子:{question}"),
        ]
    ).partial(cot=get_cot(cot_level))

.partial() 是模板的预填变量功能:先钉死 cot,剩下的 question 调用时再传。

分工原则记住一句话:system 放不变的东西,human 放每次会变的东西。 人格三周不换,题型每天变——放错了位置,改起来会互相牵连。

六、统一入口:四个开关自由组合(ask.py)

def ask(
    question: str,
    llm: BaseChatModel,
    *,
    persona: str = "strict",
    task: str = "grammar",
    level: str = "初中",
    fewshot: bool = False,
    cot: str | None = None,
) -> str:
    persona_text = get_persona(persona)

    if fewshot:
        prompt = build_fewshot_prompt(persona_text)
        messages = prompt.format_messages(question=question)
    elif cot:
        prompt = build_cot_prompt(persona_text, cot_level=cot)
        messages = prompt.format_messages(question=question)
    else:
        prompt = build_prompt(persona=persona, task=task)
        messages = prompt.format_messages(question=question, level=level)

    response = llm.invoke(messages)
    return response.content

调用方从此只写一行:ask(句子, llm, persona="examiner", fewshot=True)。

七、跑起来:真实输出

python demo.py,同一个句子 He go to school by bus every day.,三种人格渲染出的提示词对比(节选 strict):

──────── 同一个问题,三种人格(问题:He go to school by bus every day.) ────────

【strict】
[SystemMessage] 你是「启明英语」的资深语法讲师,以严格著称。
要求:
1. 直接指出错误,不铺垫、不夸奖
2. 每个错误给出:错在哪 → 为什么错 → 正确写法
3. 回答控制在 3 句以内

[SystemMessage] 请批改下面这句英语的语法,目标对象是 初中 水平的学生。
[HumanMessage] He go to school by bus every day.

离线链路自检(预置答案,验证四种人格真的走了不同分支):

  [strict     ] 时态错误:主语 He 是第三人称单数,every day 要求一般现在时,应改为 He goes。
  [encouraging] 这句有点小问题哦,'He go' 要改成 'He goes',其他都对啦!
  [examiner   ] 总分 5.0。语法项扣分:主谓不一致(He go → He goes)。

测试结果:

tests/test_ask.py::test_ask_returns_model_text PASSED
tests/test_ask.py::test_persona_text_is_really_sent PASSED
tests/test_ask.py::test_task_level_reaches_the_request PASSED
tests/test_ask.py::test_fewshot_sends_more_messages PASSED
tests/test_ask.py::test_fewshot_includes_example_content PASSED
tests/test_ask.py::test_cot_adds_guidance_to_human_message PASSED
tests/test_ask.py::test_fewshot_takes_priority_over_cot PASSED
tests/test_cot.py::test_all_levels_available PASSED
tests/test_cot.py::test_unknown_level_raises PASSED
tests/test_cot.py::test_heavy_has_more_steps_than_light PASSED
tests/test_cot.py::test_cot_text_is_sent_to_model PASSED
tests/test_cot.py::test_cot_sits_in_human_not_system PASSED
tests/test_cot.py::test_different_levels_give_different_prompts PASSED
tests/test_fewshot.py::test_examples_are_configured PASSED
tests/test_fewshot.py::test_each_example_has_input_and_output PASSED
tests/test_fewshot.py::test_fewshot_message_count PASSED
tests/test_fewshot.py::test_question_is_the_last_message PASSED
tests/test_fewshot.py::test_examples_alternate_human_ai PASSED
tests/test_fewshot.py::test_example_content_reaches_model PASSED
tests/test_prompts.py::test_default_persona_exists PASSED
tests/test_prompts.py::test_unknown_persona_raises PASSED
tests/test_prompts.py::test_unknown_task_raises PASSED
tests/test_prompts.py::test_render_has_two_system_and_one_human PASSED
tests/test_prompts.py::test_level_variable_is_interpolated PASSED
tests/test_prompts.py::test_missing_variable_raises PASSED
tests/test_prompts.py::test_different_personas_give_different_text PASSED
tests/test_prompts.py::test_render_output_is_readable PASSED

27 passed in 0.32s

27 个用例全绿,全程离线。 这组测试里我最想让你注意到 test_persona_text_is_really_sent:它用一个会记录入参的假模型,断言"阅卷官"三个字真的出现在了发给模型的请求里。

为什么这条测试重要?因为提示词开发里最阴险的 bug 就是你以为配了,实际没生效——人格名字拼错、字典取到默认值、变量传了个 None,程序都不报错,回答却悄悄走样。有了记录型假模型,这类问题一测一个准。

八、踩坑清单

  1. 人格名字写错却安静地用了默认值:get_persona() 刻意不静默兜底,错了就抛 KeyError。宁可当场合身,不要上线后走样。
  2. 示例排在提问后面:模型把示例输出当上文续写。本工程 build_fewshot_prompt 已固定顺序,别手痒调整。
  3. 示例写得敷衍:示例是模板,不是参考。示例回答的质量就是输出质量的天花板。
  4. 思维链逢题就挂:输出变长、token 变多,简单释义场景纯属浪费。只给复杂任务挂。
  5. 把输出格式约束写在 user 消息里:模型时理时不理。格式要求放 system,一次写清。
  6. pytest 报 No usable temporary directory:本机安全策略限制临时文件所致,加 --capture=no -p no:cacheprovider 关闭输出捕获即可,与代码无关。

九、本篇收尾

现在你的批改助手有了三种可切换的人格、稳定的输出结构、按需启用的解题步骤,并且每一层都有测试看着。那个"说话像换了个人"的问题,从玄学变成了一组可以打印、可以断言、可以版本管理的对象。

下一篇解决一个更烧钱的问题:聊十轮就"失忆",账单还翻倍? 我们会算清 token 这笔账,写出第一个带裁剪策略的会话管理器——你将亲眼看到"多轮对话"是怎么从免费变成账单的。


最后一句:模型不抽风,抽风的是提示词——你以为你说了要求,其实你只是许了个愿。