Prompt Engineering 實戰:Zero-shot vs Few-shot vs CoT 的 Token 成本與情感分析對比
「將原始模型輸出轉化為結構化、可靠、可預測的行為」。 這是 prompt engineering 的工程定義。其中三個層次:
- 策略:zero-shot / few-shot / Chain-of-Thought — 用什麼方式引導模型推理
- 約束:JSON mode、schema、格式規範 — 讓輸出可被程式接住
- 曡代:用標註資料集量化準確率,像調程式一樣調 prompt
Context Engineering(上下文工程):重點不是單句 prompt,而是 「在有限 context window 裡,放對的範例、對的格式說明、對的歷史」。
一、三種 prompt 策略並排比較
這邊設計腳本對同一句話組出三種 prompt,逐一送模型,並示範各策略的輸出怎麼解析:
def chat(messages, temperature=0.0):
"""呼叫真實 LLM 取得文字回覆;無任何憑證則回傳 None,由呼叫端決定是否走 offline。"""
kimi = _kimi_cfg()
try:
if kimi:
from openai import OpenAI # Kimi 為 OpenAI 相容 API
cli = OpenAI(api_key=kimi["api_key"], base_url=kimi["base_url"], timeout=120)
r = cli.chat.completions.create(model=kimi["model"], messages=messages, temperature=temperature)
return r.choices[0].message.content
if os.environ.get("ANTHROPIC_API_KEY"):
import anthropic
cli = anthropic.Anthropic()
sys_txt = "\n".join(m["content"] for m in messages if m["role"] == "system")
usr = [m for m in messages if m["role"] != "system"]
r = cli.messages.create(model="claude-opus-4-8", max_tokens=1024,
system=sys_txt or None, messages=usr, temperature=temperature)
return r.content[0].text
if os.environ.get("OPENAI_API_KEY"):
from openai import OpenAI
cli = OpenAI()
r = cli.chat.completions.create(model="gpt-4o", messages=messages, temperature=temperature)
return r.choices[0].message.content
except Exception as e:
print(f"⚠ 線上呼叫失敗,改用 offline mock:{e}", file=sys.stderr)
return None
return None
def build_prompt(task, strategy):
"""回傳 (messages, 說明) — 示範三種主流策略如何組 prompt。"""
system = "你是情感分類器。只輸出標籤本身,不要解釋。"
if strategy == "zero-shot":
user = f"判斷情感(正面/負面/中性):\n{task}"
elif strategy == "few-shot":
ex = "\n".join(f"文本:{t}\n標籤:{l}" for t, l in FEWSHOT)
user = f"依照範例判斷情感(正面/負面/中性):\n\n{ex}\n\n文本:{task}\n標籤:"
elif strategy == "cot":
user = (f"判斷以下文本的情感(正面/負面/中性)。\n"
f"先用一句話分析語氣與關鍵詞,最後一行只寫『標籤:X』。\n\n文本:{task}")
else:
raise ValueError(strategy)
return [{"role": "system", "content": system}, {"role": "user", "content": user}], strategy
執行策略看效果
python prompt_lab.py strategies --task "這家餐廳雷到爆,再也不來了"
任務文本:這家餐廳雷到爆,再也不來了
── 策略:zero-shot ──────────────────────────────
送出 user prompt:
判斷情感(正面/負面/中性):
這家餐廳雷到爆,再也不來了
模型回覆:負面
解析標籤:負面
── 策略:few-shot ──────────────────────────────
送出 user prompt:
依照範例判斷情感(正面/負面/中性):
文本:這家餐廳的服務超好,菜也好吃
標籤:正面
文本:等了一小時還上錯餐,超失望
標籤:負面
文本:還行吧,沒什麼特別的
標籤:中性
文本:這家餐廳雷到爆,再也不來了
標籤:
模型回覆:負面
解析標籤:負面
── 策略:cot ──────────────────────────────
送出 user prompt:
判斷以下文本的情感(正面/負面/中性)。
先用一句話分析語氣與關鍵詞,最後一行只寫『標籤:X』。
文本:這家餐廳雷到爆,再也不來了
模型回覆:分析:語氣強烈負面,關鍵詞「雷到爆」、「再也不來了」顯示極度不滿。
標籤:負面
解析標籤:負面
觀察點:zero-shot 最省 token 但最不穩;few-shot 用範例鎖定格式;CoT 準度高但回覆需額外解析。
關鍵在這兩行 prompt
user = (f"判斷以下文本的情感(正面/負面/中性)。\n"
f"先用一句話分析語氣與關鍵詞,最後一行只寫『標籤:X』。\n\n文本:{task}")
「先用一句話分析…最後一行只寫標籤」 這句話就是 CoT 的全部。它要求模型先推理、再下結論,而不是直接吐答案。這就是 Chain of Thought——讓模型把「思考過程」寫出來,而不是心算。
對照你的輸出,它確實發生了
模型的回覆分成兩段:
分析:語氣強烈負面,關鍵詞「雷到爆」、「再也不來了」顯示極度不滿。 ← 這是「思考鏈」(推理步驟)
標籤:負面 ← 這是最終結論
第一行就是模型「想出來」的推理過程(chain of think),第二行才是答案。對比另外兩個策略:
| 策略 | 模型被要求做什麼 | 回覆長相 |
|---|---|---|
| zero-shot | 直接給標籤 | 只有 負面 |
| few-shot | 照範例給標籤 | 只有 負面 |
| cot | 先分析再給標籤 | 分析:…\n標籤:負面 |
程式碼唯一為 CoT 做的特殊處理
CoT 不是程式做的,是「叫模型自己一步步想」的一句提示詞做的。 程式碼只是配合它「答案在最後一行」的特性去解析。

三種不同策略的 token 消耗分析
| 策略 | 輸入 token | 輸出 token | 說明 |
|---|---|---|---|
| zero-shot | 最少 | 最少(1 個標籤) | prompt 短,回覆只有 負面 |
| few-shot | 最多(塞了 3 個範例) | 最少(1 個標籤) | 範例佔滿 input |
| cot | 中等 | 最多(分析+標籤) | 逼模型多寫一段推理 |
CoT 貴在哪裏
貴在輸出端。看你剛纔的回覆:
分析:語氣強烈負面,關鍵詞「雷到爆」、「再也不來了」顯示極度不滿。 ← 多出來的 output token
標籤:負面
zero-shot 只要輸出 負面(約 1-2 token),CoT 卻要輸出整段分析(約 30-40 token)。這段推理文字全部按 output token 計費,而 output 單價通常是 input 的 2-4 倍。所以同一個分類任務,CoT 的單次成本可能是 zero-shot 的好幾倍。
這邊在 chat 下加入 token 獲取
r = cli.chat.completions.create(model=kimi["model"], messages=messages, temperature=temperature)
print(f" token 用量:{r.usage}") # prompt_tokens / completion_tokens / total_tokens
return r.choices[0].message.content
再執行一次可以看到
token 用量:CompletionUsage(completion_tokens=3, prompt_tokens=59, total_tokens=62, completion_tokens_details=None, prompt_tokens_details=None)
── 策略:zero-shot ──────────────────────────────
token 用量:CompletionUsage(completion_tokens=3, prompt_tokens=142, total_tokens=145, completion_tokens_details=None, prompt_tokens_details=None)
── 策略:few-shot ──────────────────────────────
token 用量:CompletionUsage(completion_tokens=43, prompt_tokens=91, total_tokens=134, completion_tokens_details=None, prompt_tokens_details=None)
── 策略:cot ──────────────────────────────
實測 token 對照
| 策略 | prompt(輸入) | completion(輸出) | total | 貴在哪 |
|---|---|---|---|---|
| zero-shot | 59 | 3 | 62 | 兩邊都最省 |
| few-shot | 142 | 3 | 145 | 輸入非常多(塞了 3 個範例) |
| cot | 91 | 43 | 134 | 輸出多很多(多寫了推理) |
三個關鍵觀察
1. few-shot 貴在輸入,cot 貴在輸出——完全對稱
- few-shot:
prompt 59 → 142,多了 83 token,全是那 3 條範例撐大的。輸出照樣只有 3。 - cot:
completion 3 → 43,多了 40 token,全是「分析:語氣強烈負面…」那段推理。輸入只小漲。
2. total 看起來 few-shot 最貴,但實際帳單未必
單看 total,few-shot(145)> cot(134)。但計費是輸入、輸出分開乘各自單價的,而 output 通常貴 2-4 倍。假設 output 單價是 input 的 3 倍,用「等效 input token」重算:
| 策略 | 等效成本 = prompt + completion×3 |
|---|---|
| zero-shot | 59 + 9 = 68 |
| few-shot | 142 + 9 = 151 |
| cot | 91 + 129 = 220 ← 反而最貴 |
換算真實計價後,CoT 是最貴的那個,因為成本集中在單價高的 output 端。光看 total_tokens 會誤判。
3. completion=3 印證中文 token 很碎
負面 兩個字卻是 3 個 token——中文常常一字 ≥1 token,還可能加個結束符。不能單純用「字數」估 token 了。詳細要拆解token還必須看不同 llm 的 tokenizer 設計,通常也不一樣,只需要記得這件事即可。
二、JSON mode + 穩健解析 + 自動修復
接下來要用 schema 約束輸出,並寫出能容忍 ```fence ````、雜訊的解析器
python prompt_lab.py json-extract --text "張先生,0912-345-678,下週三下午三點看診"
def cmd_json_extract(args):
schema = {"name": "姓名", "phone": "電話", "datetime": "預約時間"}
system = "你是資訊抽取器,只輸出符合 schema 的 JSON 物件,不要任何多餘文字。"
user = (f"從文本抽取欄位並輸出 JSON:\n"
f"schema = {json.dumps(schema, ensure_ascii=False)}\n"
f"找不到的欄位填 null。\n\n文本:{args.text}")
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
for attempt in range(1, 3):
out = llm(messages, args.offline)
data = extract_json(out)
if data is not None:
print(f"✓ 第 {attempt} 次解析成功:")
print(json.dumps(data, ensure_ascii=False, indent=2))
missing = [k for k in schema if k not in data]
print("✓ 欄位齊全" if not missing else f"⚠ 缺欄位:{missing}")
return
print(f"✗ 第 {attempt} 次解析失敗,回覆為:{out[:80]!r}")
# 自動修復:把錯誤回饋塞回對話,要求「只回 JSON」
messages.append({"role": "assistant", "content": out})
messages.append({"role": "user", "content": "你的輸出無法被 json.loads 解析,請只輸出合法 JSON 物件。"})
print("✗ 兩次都失敗 — 真實情境下應記錄、降級或人工介入。")
# ─────────────────────────────────────────────────────────────────────────────
# 穩健 JSON 解析:容忍 ```fence```、前後雜訊,抓第一個完整物件
# ─────────────────────────────────────────────────────────────────────────────
def extract_json(text):
t = text.strip()
if "```" in t: # 去除 markdown code fence
t = t.split("```")[1] if t.count("```") >= 2 else t.replace("```", "")
t = t[4:] if t.lower().startswith("json") else t
s, e = t.find("{"), t.rfind("}")
if s == -1 or e == -1:
return None
try:
return json.loads(t[s:e + 1])
except json.JSONDecodeError:
return None
執行結果如下:
J:\git\mathTalk\class\AI-Engineer-Course\05-Prompt-Engineering>python prompt_lab.py json-extract --text "張先生,0912-345-678,下週三下午三點看診"
token 用量:CompletionUsage(completion_tokens=38, prompt_tokens=109, total_tokens=147, completion_tokens_details=None, prompt_tokens_details=None)
✓ 第 1 次解析成功:
{
"name": "張先生",
"phone": "0912-345-678",
"datetime": "下週三下午三點"
}
✓ 欄位齊全
在這裏可以看到 schema 的範本如下:
schema = {"name": "姓名", "phone": "電話", "datetime": "預約時間"}
抽取結果為合法 JSON,欄位齊全(name / phone / datetime)
不過當我胡亂修改内容時就會出現意料外的情況
python prompt_lab.py json-extract --text "張先生,0912-34512323-678,151234下週三下午三點看診"
token 用量:CompletionUsage(completion_tokens=41, prompt_tokens=114, total_tokens=155, completion_tokens_details=None, prompt_tokens_details=None)
✓ 第 1 次解析成功:
{
"name": "張先生",
"phone": "0912-34512323-678",
"datetime": "下週三下午三點"
}
✓ 欄位齊全
實際上這個電話並不合理 "phone": "0912-34512323-678",