Prompt 是一种新型契约,越像规范,输出越稳

Prompt 不是聊天,是写规范

很多新手对 Prompt 的第一印象是"我跟 AI 聊几句它就懂了"。这种聊天式的用法做娱乐性 demo 没问题,但只要你想把 LLM 嵌进任何严肃产品流程里,第一件事就要做思维转换:Prompt 不是聊天稿,而是一份给模型看的需求规范。规范越清晰、约束越明确,输出就越稳定可控。

可以用 Python 的视角类比:传统函数靠类型签名和 docstring 来约束行为,调用者只能按签名传参;而 Prompt 是用自然语言写的"函数签名",模型按它的理解去执行。两者的 API 契约位置是对应的——只是表达方式从代码变成了自然语言。

# 传统函数:契约写在签名里
def classify_email(content: str) -> Literal["spam", "normal"]:
    """判断邮件是否为垃圾邮件。"""
    ...

# Prompt:契约写在文本里
prompt = """\
你是一个邮件分类器。
任务:判断给定邮件正文是垃圾邮件还是正常邮件。
输出:只返回 "spam" 或 "normal",不要任何解释或额外文字。

邮件正文:
{content}
"""

理解到这一层,你就能开始系统化地写 Prompt,而不是反复"再试一种问法"。

一份合格 Prompt 的四件套

把任意一个工业级 Prompt 拆开看,几乎都包含下面四个部分。哪怕只是一段简单分类任务,把这四块写齐,效果会比拍脑袋的版本稳定得多。

1. 角色(Role)——把模型置入一个具体场景。这一步看起来玄学,但有实证效果:被设定成"资深 Python 工程师"的模型在写代码时确实比"AI 助手"更接近你想要的风格。

2. 任务(Task)——明确说出"你要做什么",一句话写清核心动作。避免"帮我看看""分析一下"这种模糊词。

3. 约束(Constraints)——明确说出"不能做什么 / 要满足什么条件"。这一块最容易被新手忽略,也最影响稳定性。模型默认会"努力多帮你做点事",没有约束就会输出多余的解释、铺垫、注意事项。

4. 输出格式(Output Format)——明确说出"返回的形态"。如果下游要程序解析,就必须严格定义 JSON / 标签 / 行格式。

一个把四件套写齐的 Prompt 长这样:

PROMPT = """\
# 角色
你是一位资深的 Python 代码审查员,专长是发现 hooks 误用、性能陷阱和安全问题。

# 任务
审查下面这段 Python 代码,找出潜在的 bug 和改进建议。

# 约束
- 只关注正确性、性能、安全三个维度
- 不要讨论代码风格、命名、注释格式
- 最多输出 5 条建议
- 每条建议必须能在代码中定位到具体行号

# 输出格式
严格按以下 JSON 数组返回,不要任何其他文字:
[
  {{"line": 12, "severity": "high", "issue": "...", "fix": "..."}}
]

# 待审查代码
{code}
"""

注意 Python 字符串里 {{}} 是为了和 .format() 转义搭配使用,下文会讲到。

烂 Prompt 与好 Prompt 的对比

为了让差距更直观,给同一个需求"从一段招聘 JD 里提取关键信息"看两个版本。

烂版本:

帮我看看下面这个 JD 里有什么有用信息。

{jd_text}

好版本:

你是一个招聘信息抽取器。
任务:从 JD 文本中提取以下字段。

输出要求:
- 严格 JSON,不要 markdown 代码块包裹
- 字段缺失时填 null,不要编造
- salary_range 用 "min-max k" 的格式(如 "20-35k")

字段:
{
  "title": "职位名",
  "company": "公司名",
  "location": "城市",
  "salary_range": "薪资区间",
  "must_have_skills": ["必备技能列表"],
  "years_required": "工作年限要求(数字)"
}

JD 文本:
{jd_text}

两者送给同一个模型,烂版本的输出可能是一段散文式的总结,每次结构都不一样;好版本几乎一定是合法 JSON,可以直接 json.loads 解析。差距就在"是否把契约写清楚"。

Few-shot:用例子代替说明

有些任务很难用文字说清楚(比如特定的语气、风格、行业术语映射),但用几个例子一展示就秒懂。这时候用 Few-shot Prompting——在指令后面附上几组 input/output 示例,模型会模仿这个模式作答。

PROMPT = """\
你的任务是把口语化的需求改写成正式的产品需求文档(PRD)条目。

例子:

输入:能不能搞个东西,让用户能一键导出他的所有聊天记录到本地
输出:作为一名用户,我希望能在设置中点击"导出聊天记录"按钮,将我的全部对话历史以 ZIP 格式下载到本地,以便我能离线归档与备份。

输入:希望搜索快一点,现在等半天
输出:作为一名用户,我希望搜索结果能在 200 毫秒内返回,以便我能在不打断思考流的情况下快速找到目标内容。

输入:{user_text}
输出:"""

经验:示例数量通常 2~5 个之间最划算。0 个(zero-shot)够用就别加;超过 5 个的边际效果递减,但 Token 成本线性上升。

思维链(Chain-of-Thought)

对涉及推理、计算、多步分析的任务,加上一句"请一步步思考"或者明确要求"先分析再得出结论",能显著提升正确率。这背后的原理可以理解成:模型在生成最终答案之前,先把自己的推理过程"打草稿"输出出来,相当于借文本本身做了一次外置工作记忆。

PROMPT = """\
判断下面这段代码运行后会输出什么。

请按以下步骤回答:
1. 先逐行分析代码的执行过程
2. 列出关键变量在每一步的值
3. 最后用一行 "答案:xxx" 给出输出结果

代码:
{code}
"""

注意这种写法会让模型输出变长,意味着 Token 消耗增加、响应变慢。如果你只想要最终答案不想看推理过程,可以让模型把推理放在 <thinking> 标签里,再程序剥掉:

import re

text = response_content
final = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL).strip()

最新的"推理模型"(OpenAI o 系列、DeepSeek R1)已经把这套机制内置进训练,不再需要你显式写"一步步思考",但理解原理对调试普通模型仍然有用。

控制随机性:temperature

LLM 输出的不确定性主要由 temperature 控制,取值范围 0~2,默认通常 1。粗略理解:

  • 0 附近——模型每次几乎选概率最高的下一个 token,输出最稳定。适合分类、提取、代码生成等"有标准答案"的任务
  • 0.7~1.0——保留一定多样性。适合创意写作、聊天、头脑风暴
  • >1.5——非常随机,容易胡言乱语,几乎没人这么用

抽取信息这类任务别忘了把 temperature 设成 0:

chat(messages, temperature=0)

光靠 temperature=0 并不能让输出 100% 一致(GPU 浮点运算本身有微小不确定性),但可以把变化控制在很小范围内。

输出 JSON:靠纯 Prompt 的几个技巧

很多场景下你需要模型输出可解析的 JSON。在不用结构化输出 API 的前提下,这几招能显著提高合法率:

1. 在 Prompt 末尾就开个头:在指令最后一行直接写 JSON: 或者 {,强制模型从这里开始。

2. 明确禁止 markdown 包裹:模型默认很爱用 ```json ... ``` 把 JSON 包起来。一句"不要使用代码块包裹,直接输出 JSON"通常能压住。

3. 给结构示范而不是描述:与其用文字说"返回一个有 name 和 age 字段的对象",不如直接把目标 JSON 模板贴进去当例子。

4. 限定字段值:能用枚举就用枚举(如 severity 只允许 low|medium|high),别让模型自由发挥。

5. 给一个解析失败的兜底

import json

raw = chat(messages, temperature=0)
try:
    data = json.loads(raw)
except json.JSONDecodeError:
    # 常见情况:被 ```json ``` 包裹了
    cleaned = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE)
    data = json.loads(cleaned)

这些技巧可以让你解决 80% 的结构化输出问题。剩下那 20% 难啃的边界情况,第 04 篇会用 Pydantic 和 response_format 参数彻底处理。

Prompt 模板化管理

随着项目变大,把 Prompt 塞在 Python 代码里会很难维护。两条建议:

模板字符串用 .format() 或 Jinja2

简单场景用 .format()

PROMPT_TEMPLATE = """\
你是一个 {role}
任务:{task}

输入:{input}
"""

prompt = PROMPT_TEMPLATE.format(role="翻译器", task="把英文译成中文", input="hello")

复杂模板(条件分支、循环、嵌套)用 Jinja2:

from jinja2 import Template

tmpl = Template("""\
你是一个 {{ role }}

{% if examples %}
参考示例:
{% for ex in examples %}
- 输入:{{ ex.input }}
  输出:{{ ex.output }}
{% endfor %}
{% endif %}

请处理:{{ input }}
""")

prompt = tmpl.render(role="分类器", examples=[...], input="...")

Prompt 抽到独立文件

把每个 Prompt 写成一个 .md.txt 文件放在 prompts/ 目录下,代码里通过 Path(...).read_text() 加载。这样 Prompt 的改动不需要改 Python 代码,也方便做版本对比和 A/B 测试:

from pathlib import Path

PROMPTS_DIR = Path(__file__).parent / "prompts"

def load_prompt(name: str) -> str:
    return (PROMPTS_DIR / f"{name}.md").read_text(encoding="utf-8")

prompt = load_prompt("email_classifier").format(content=email_text)

别用 Prompt 解决一切

最后一条经验,省你以后很多痛苦:不是所有问题都靠加 Prompt 解决。看到下面这些信号,应该考虑换工程手段而不是继续加规则:

  • Prompt 长到接近 2000 字,里面规则互相打架,模型时灵时不灵——拆任务分多步调用
  • 输出格式经常坏,加再多"必须返回 JSON"也没用——上结构化输出(第 04 篇)
  • 模型不知道某个事实然后开始编——上 RAG(第 05、06 篇)
  • 模型需要查实时数据或调用工具——上 Function Calling(第 07 篇)

Prompt 是底层工具,但它有边界。识别边界比把 Prompt 写得更花哨更重要。

下一篇

第 04 篇会进入 结构化输出:靠 Pydantic + response_format 把 LLM 的输出变成可以直接当 Python 对象用的强类型结果。这是 Python 生态做 AI 应用相对其他语言最有优势的一块,也是把 LLM 真正"嵌进系统"的关键一步。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:Prompt 工程:让模型稳定输出你要的结果

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/03-Prompt工程/

本文最后一次更新为 天前,文章中的某些内容可能已过时!