DeepLearning.AI 是吴恩达创办的 AI 教育平台。《ChatGPT Prompt Engineering for Developers》短课讲了写提示词的两条原则(写清楚具体的指令、给模型思考的时间)和“迭代式”开发提示词。 这篇把这些方法用在一个自己的小任务上:从商品评论中判断情绪并抽取评价方面。做一个
run_eval.py,对同一组评论跑三版提示词,用准确率和 F1 打分,靠数字而不是感觉来改提示词。
先看清成品
- 在本机 qwen2.5 1.5B 上真实跑了 v1/v2/v3 三版:情绪准确率 1/6 → 4/6 → 4/6,方面 F1 0.52 → 0.63 → 0.69。
- 发现 v1 的低分一半是评分问题:模型说“积极的”“不满”,不是标签本身。
- v3 根据 v2 的实际错误修改(多列方面、把“有好有坏”判成负面),方面改善了,“中性”仍然判错,没有硬凑结果。5 项 pytest 通过。
合规提醒:DeepLearning.AI 的课程视频、Notebook 和讲义受版权保护,按其使用条款只供个人学习。本文不复制、不抓取任何课程内容,只把课程讲到的方法用自己写的数据和代码重新做一遍。课程是否免费、证书规则以 deeplearning.ai 页面为准。
运行说明:本文代码全部在 box 上用本机 Ollama 小模型真实运行过(2026-10-21,UTC+8,时间见各段),输出原样贴出。课程本身用的是 OpenAI API,我没有 OpenAI Key,也没有在课程的在线 Notebook 里运行。小模型的分数只用来对比方法,不代表大模型的水平。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
reviews.jsonl |
6 条评论和我标注的答案(我编的) |
prompts.py |
三版提示词 |
run_eval.py |
跑分脚本 |
test_run_eval.py |
5 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install openai pytest |
| Ollama | 本机运行,模型 qwen2.5:1.5b 一类的小模型即可(本文用的是 box 上已有的 qwen2.5-local:1.5b) |
关键事实
| 事实 | 来源 |
|---|---|
| 课程讲的内容:两条提示原则、迭代式开发,以及总结、推断(情绪分类、主题抽取)、转换、扩写四类任务和聊天机器人 | 课程页面 |
| 课程用 OpenAI API,在课程自带的 Jupyter 环境里练习 | 课程页面 |
Ollama 提供 OpenAI 兼容的 /v1/chat/completions,可直接用 openai SDK |
Ollama 官方文档 |
文件结构
87-deeplearning-ai-A-src/
├── reviews.jsonl
├── prompts.py
├── run_eval.py
└── test_run_eval.py
第 1 步:评论和标注
{"id":1,"text":"耳机音质很好,但是左耳两周后就没声音了,联系客服到现在没人回。","sentiment":"负面","topics":["质量","客服"]}
{"id":2,"text":"台灯亮度可调,晚上看书不累眼,快递第二天就到了。","sentiment":"正面","topics":["功能","物流"]}
{"id":3,"text":"保温杯外观和图片一样,保温效果一般,价格还算合理。","sentiment":"中性","topics":["外观","质量","价格"]}
{"id":4,"text":"包装破了,杯子摔出一道裂缝,申请退货三天才审核通过。","sentiment":"负面","topics":["物流","售后"]}
{"id":5,"text":"第三次回购了,孩子很喜欢这个味道。","sentiment":"正面","topics":["口味"]}
{"id":6,"text":"说明书全是英文,研究了半小时才装好,用起来倒是挺稳。","sentiment":"中性","topics":["说明书","质量"]}
第 2 步:三版提示词
"""两版提示词。v2 按课程讲的两条原则改写:
原则一 写清楚、具体的指令(用分隔符框住输入、要求结构化输出、限定取值);
原则二 给模型“思考”的步骤(先找出评价对象,再判断)。"""
TOPICS = ["质量", "客服", "功能", "物流", "外观", "价格", "售后", "口味", "说明书"]
V1 = "这条评论是什么情绪?涉及哪些方面?\n{text}"
V2 = """你会收到一条商品评论,用三个反引号括起来。按步骤完成:
1. 列出评论里提到的每个方面,只能从这个列表中选:{topics}
2. 对每个方面判断好坏。
3. 给出整体情绪:好坏都有、或只是陈述事实时为“中性”;只有好评为“正面”;只有差评为“负面”。
只输出 JSON,不要其他文字,格式:{{"sentiment": "正面|负面|中性", "topics": ["..."]}}
```{text}```"""
def render(version, text):
return VERSIONS[version].format(text=text, topics="、".join(TOPICS))
# v3:根据 v2 的实际错误迭代(课程第二部分讲的“迭代式”开发)——
# v2 会把没提到的方面也列上,还把“有好有坏”判成负面。加一条限制和一个示例(few-shot)。
V3 = V2.replace("1. 列出评论里提到的每个方面", "1. 只列出评论里明确提到的方面(没提到的不要列)") \
.replace("只输出 JSON", """示例:
```外卖送得慢,但是菜还是热的,味道不错。```
{{"sentiment": "中性", "topics": ["物流", "口味"]}}
只输出 JSON""")
VERSIONS = {"v1": V1, "v2": V2, "v3": V3}
第 3 步:跑分脚本
"""对同一组评论跑两版提示词,统计情绪准确率和方面的 F1。默认连本机 Ollama 的 OpenAI 兼容接口。"""
import argparse, json, re, sys, time
from openai import OpenAI
from prompts import render, TOPICS
SENTS = ("正面", "负面", "中性")
def parse(raw):
"""v1 的回答是自由文本,v2 应该是 JSON;都尽量解析,解析不出就记为 None。"""
m = re.search(r"\{.*\}", raw, re.S)
if m:
try:
o = json.loads(m.group(0))
s = o.get("sentiment"); t = o.get("topics") or []
return (s if s in SENTS else None), [x for x in t if x in TOPICS]
except json.JSONDecodeError:
pass
found = [s for s in SENTS if s in raw]
return (found[0] if len(found) == 1 else None), [t for t in TOPICS if t in raw]
def f1(pred, gold):
p, g = set(pred), set(gold)
if not p and not g: return 1.0
tp = len(p & g)
return 0.0 if tp == 0 else 2 * tp / (len(p) + len(g))
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="qwen2.5-local:1.5b")
ap.add_argument("--base-url", default="http://localhost:11434/v1")
ap.add_argument("--data", default="reviews.jsonl")
ap.add_argument("--versions", default="v1,v2,v3")
a = ap.parse_args(argv)
client = OpenAI(base_url=a.base_url, api_key="ollama")
rows = [json.loads(l) for l in open(a.data, encoding="utf-8") if l.strip()]
for v in a.versions.split(","):
acc, fs, t0 = 0, [], time.time()
for r in rows:
raw = client.chat.completions.create(model=a.model, temperature=0,
messages=[{"role": "user", "content": render(v, r["text"])}]).choices[0].message.content
s, t = parse(raw)
acc += s == r["sentiment"]; fs.append(f1(t, r["topics"]))
print(f"{v} #{r['id']} 情绪={s}(应为 {r['sentiment']}) 方面={t} 原始:{raw.strip()[:50]!r}")
print(f"== {v}: 情绪准确 {acc}/{len(rows)},方面平均 F1 {sum(fs)/len(fs):.2f},用时 {time.time()-t0:.1f}s\n")
if __name__ == "__main__":
sys.exit(main())
第 4 步:测试
from run_eval import parse, f1
from prompts import render
def test_parse_fenced_json():
assert parse('```json\n{"sentiment":"负面","topics":["物流","不存在"]}\n```') == ("负面", ["物流"])
def test_parse_free_text_synonym_is_none():
# 真实遇到的:v1 回答“积极的”“不满”,不是标签本身,记为 None 而不是瞎猜
assert parse("这条评论是积极的。")[0] is None
def test_parse_ambiguous_free_text():
assert parse("有正面也有负面")[0] is None
def test_f1():
assert f1(["a", "b"], ["a"]) == 2 / 3 and f1([], []) == 1.0 and f1(["x"], ["y"]) == 0.0
def test_render_has_delimiters_and_example():
p = render("v3", "测试")
assert "```测试```" in p and "示例" in p and ",," not in p
第 5 步:运行
# 2026-10-21 21:49 UTC+8,v1 和 v2
$ python run_eval.py --versions v1,v2
v1 #2 情绪=None(应为 正面) 方面=['功能', '物流'] 原始:'这条评论是积极的。它涉及了产品功能(台灯亮度可调)和物流速度(快递第二天就到了)。'
v1 #3 情绪=None(应为 中性) 方面=['外观', '价格'] 原始:'这条评论涉及的方面包括:\n1. 保温效果:评论中提到保温效果一般,这表明保温杯的保温性能没有达到预期'
v1 #4 情绪=None(应为 负面) 方面=[] 原始:'这条评论的情绪是不满和失望。它涉及了以下几个方面:\n\n1. **包装问题**:评论提到了包装破了,杯'
v1 #5 情绪=正面(应为 正面) 方面=[] 原始:'这条评论表达的情绪是正面的。它涉及的方面包括:\n1. 购买行为:评论中提到“第三次回购”,表明孩子对'
v1 #6 情绪=正面(应为 中性) 方面=['说明书'] 原始:'这条评论的情绪是正面的。它涉及了以下几个方面:\n\n1. **产品使用体验**:评论者提到“用起来倒是'
== v1: 情绪准确 1/6,方面平均 F1 0.52,用时 12.0s
v2 #1 情绪=负面(应为 负面) 方面=['功能', '售后'] 原始:'```json\n{\n "sentiment": "负面",\n "topics": ["功能", '
v2 #2 情绪=正面(应为 正面) 方面=['质量', '功能', '物流', '外观'] 原始:'```json\n{\n "sentiment": "正面",\n "topics": ["质量", '
v2 #3 情绪=负面(应为 中性) 方面=['外观', '质量', '价格'] 原始:'```json\n{\n "sentiment": "负面",\n "topics": ["外观", '
v2 #4 情绪=负面(应为 负面) 方面=['质量', '物流', '售后'] 原始:'```json\n{\n "sentiment": "负面",\n "topics": ["质量", '
v2 #5 情绪=正面(应为 正面) 方面=['口味'] 原始:'```json\n{\n "sentiment": "正面",\n "topics": ["口味"]\n'
v2 #6 情绪=负面(应为 中性) 方面=['质量', '功能', '物流', '外观', '价格'] 原始:'```json\n{\n "sentiment": "负面",\n "topics": ["质量", '
== v2: 情绪准确 4/6,方面平均 F1 0.63,用时 5.0s
# 21:50 UTC+8,v3
$ python run_eval.py --versions v3
v3 #2 情绪=正面(应为 正面) 方面=['功能', '物流', '外观'] 原始:'```json\n{\n "sentiment": "正面",\n "topics": ["功能", '
v3 #3 情绪=负面(应为 中性) 方面=['外观', '物流', '价格'] 原始:'```{"sentiment": "负面", "topics": ["外观", "物流", "价格"'
v3 #4 情绪=负面(应为 负面) 方面=['物流', '售后'] 原始:'```json\n{\n "sentiment": "负面",\n "topics": ["物流", '
v3 #5 情绪=正面(应为 正面) 方面=['口味'] 原始:'```json\n{\n "sentiment": "正面",\n "topics": ["口味"]\n'
v3 #6 情绪=负面(应为 中性) 方面=['说明书'] 原始:'```{"sentiment": "负面", "topics": ["说明书"]}'
== v3: 情绪准确 4/6,方面平均 F1 0.69,用时 3.8s
$ python -m pytest -q
5 passed
怎么检查结果?
- temperature=0,同一版提示词再跑一次结果相同(我连跑两次 v3,结果一致)。
- 看每条的“原始”输出,分清是模型答错,还是答对了但格式解析不出。
- 标注是我自己做的,第 3、6 条算“中性”有主观成分;换成你自己的任务时,先找一个人一起标,减少主观偏差。
调试示例
1. v1 只有 1/6,但很多不是模型答错,是评分拿不到答案。 看原始输出:“这条评论的情绪是不满和失望”“这条评论是积极的”。意思对了,但不是“正面/负面/中性”三个词。解析器只认这三个词(不去猜“积极”算不算正面),所以记为 None。这说明课程讲的“要求结构化输出”首先是为了程序能处理,不只是好看。测试 test_parse_free_text_synonym_is_none 固定了这条规则:宁可记为未知,也不猜。
2. v2 格式对了,但方面列得太多、混合评价判成负面。 第 6 条(说明书是英文、但用着稳)v2 列了 5 个方面;第 3 条(外观好、保温一般、价格合理)判成负面。按课程讲的迭代方法,v3 只针对这两个错误改:加一句“没提到的不要列”,再加一个“有好有坏 = 中性”的示例。结果第 6 条的方面从 5 个降到 1 个,F1 从 0.63 到 0.69;但两条“中性”还是判成负面。1.5B 模型可能就到这里了,下一步应该换更大的模型,而不是继续往提示里堆规则。
3. 我自己写的 v3 有个小错。 第一次用字符串替换生成 v3 时,打印出来是“(没提到的不要列),,只能从…”——两个逗号,因为被替换的原文后面本来就有一个逗号。修正后加了测试,检查渲染出的提示里没有“,,”。提示词也是代码,改完要打印出来看一遍。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 分数忽高忽低 | temperature 不为 0 | 设成 0 |
| 解析失败多 | 没要求 JSON 或模型不听 | 要求 JSON 并给示例;看原始输出 |
| 改了提示反而变差 | 一次改太多 | 一次只改一处,再跑分 |
| 样本太少 | 6 条只够演示 | 实际用至少几十条 |
模板规则
- 先写评测集,再改提示词。
- 一次只改一处,改完重新跑分。
- 结构化输出 + 固定取值。
- 保留原始输出,便于分析错误。
项目资源
| 用途 | 地址 |
|---|---|
| DeepLearning.AI 短课列表 | https://www.deeplearning.ai/short-courses/ |
| DeepLearning.AI 使用条款 | https://www.deeplearning.ai/terms-of-use/ |
| Ollama OpenAI 兼容接口(官方) | https://docs.ollama.com/api/openai-compatibility |
| ChatGPT Prompt Engineering for Developers 课程页 | https://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/ |
课程教的是方法,用在自己的数据上并量化结果,才算真正学会。