System Prompt 與多輪對話
上一堂課你學會了選模型、估成本——但你的程式和 Claude 說話的方式,還停在「送一句問題、拿一個答案」的層次。現實的產品不是這樣的:用戶會接著追問、Claude 需要記住剛才說了什麼、你要讓 Claude 用特定的角色和風格回應。這三件事背後都指向同一個機制:messages 陣列的正確組裝方式,以及 system prompt 的工程方法。
很多人第一次寫多輪對話時會踩到一個很蠢但很常見的坑:「我跟 Claude 說完前三輪了,第四輪為什麼它不記得?」答案是:Claude 的 Messages API 是完全無狀態的。你每次送請求,它都只看你這次帶進去的 messages 陣列——你不帶歷史,它就沒有記憶。這堂課把這個機制從頭說清楚。
這堂學什麼
system和user角色的本質差異:為什麼 system prompt 不只是「第一句話」- System prompt 工程三板斧:角色定義、行為邊界、輸出格式——每種都有具體範本
- Messages API 的無狀態本質:你要自己帶歷史,不帶就沒記憶
- 對話歷史越來越長的危機:四種裁剪策略與選擇時機
temperature、top_p、max_tokens的實際作用與推薦值- 手把手實戰:寫一支有人設的互動式 CLI 對話程式(Python 主,附 JS)
觀念一:system 和 user 是兩條不同的指令管道
初學者常把 system prompt 理解為「對話的第一句話」,這個理解是錯的。在 Claude 的眼裡,system 和 user 是兩個完全不同的指令層:

| system | user | |
|---|---|---|
| 誰設定 | 開發者(你) | 終端用戶(或你模擬的用戶) |
| 出現時機 | 每次 API 呼叫都攜帶,但在對話之前生效 | 對話過程中每一輪的輸入 |
| 用途 | 定義角色、規則、輸出格式、行為邊界 | 實際的問題或任務 |
| 用戶可見性 | 通常對終端用戶隱藏 | 用戶自己輸入的,當然看得到 |
在 API 的參數設計上,這兩者也是分開的:system 是獨立的頂層參數,不在 messages 陣列裡:
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="你是一位專業的技術文件審查員,只用繁體中文回應,語氣精準直接。", # 獨立參數
messages=[
{"role": "user", "content": "幫我看這段程式碼有沒有問題。"}
]
)
// JavaScript 版本
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: '你是一位專業的技術文件審查員,只用繁體中文回應,語氣精準直接。',
messages: [
{ role: 'user', content: '幫我看這段程式碼有沒有問題。' }
]
});
觀念二:System Prompt 工程三板斧
一個爛的 system prompt 跟一個好的 system prompt,效果差距可以非常大。實作上有三個核心要素值得你花時間:

板斧一:角色定義
告訴 Claude 它是誰、背景是什麼、用什麼語氣說話。角色越具體,輸出越穩定:
你是「CodeReview Bot」,一位有十年 Python 後端開發經驗的資深工程師。
你只用繁體中文回應。語氣直接、不廢話,只給事實和建議,不需要加客套語。
板斧二:行為邊界
明確說明 Claude 不應該做什麼、超出範圍時應如何反應:
你只負責審查程式碼,不回答與程式無關的問題。
如果用戶問的不是程式問題,回覆:「這不在我的服務範圍內。請提供你想審查的程式碼。」
不要猜測用戶的意圖,如果程式碼不完整,直接要求對方補充。
板斧三:輸出格式
告訴 Claude 輸出結構長什麼樣——這對下游解析至關重要:
每次程式碼審查的回應格式如下:
【問題清單】
- 嚴重度(高/中/低):具體問題描述
(如果沒有問題,寫「未發現明顯問題」)
【建議修改】
附上修改後的程式碼片段(如果有需要)
【一句話總結】
整體評估,不超過 30 字。
把三板斧合起來,完整 system prompt 長這樣:
SYSTEM_PROMPT = """你是「CodeReview Bot」,一位有十年 Python 後端開發經驗的資深工程師。
你只用繁體中文回應。語氣直接、不廢話,只給事實和建議,不需要客套語。
你只負責審查程式碼,不回答與程式無關的問題。
如果用戶問的不是程式問題,回覆:「這不在我的服務範圍內。請提供你想審查的程式碼。」
如果程式碼不完整或缺少上下文,直接說明需要補充什麼。
每次程式碼審查的回應格式如下:
【問題清單】
- 嚴重度(高/中/低):具體問題描述
(如果沒有問題,寫「未發現明顯問題」)
【建議修改】
附上修改後的程式碼片段(如果有需要)
【一句話總結】
整體評估,不超過 30 字。"""
觀念三:多輪對話的 messages 陣列
現在來面對那個最關鍵的問題:Claude 怎麼「記住」之前說了什麼?
答案是:你自己維護歷史,每次請求都把完整歷史帶進去。Claude 的 Messages API 是完全無狀態的,Anthropic 的伺服器不幫你儲存對話——每次請求對它來說都是一次全新的呼叫。

messages 陣列有兩個硬規則:
- role 必須交替出現:user → assistant → user → assistant,不能連續兩個同 role
- 第一條必須是 user,最後一條也必須是 user(這樣 API 才知道要 Claude 繼續回應)
組裝多輪對話的標準模式:
import anthropic
client = anthropic.Anthropic()
# 維護一個對話歷史 list
conversation_history = []
def chat(user_message: str, system_prompt: str = "") -> str:
"""送出一輪對話,自動維護歷史"""
# 把這輪用戶訊息加進去
conversation_history.append({
"role": "user",
"content": user_message
})
# 每次都帶完整歷史
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=system_prompt,
messages=conversation_history
)
# 把 Claude 的回應也存進歷史
assistant_message = response.content[0].text
conversation_history.append({
"role": "assistant",
"content": assistant_message
})
return assistant_message
# 使用範例
system = "你是一位友善的 Python 助理,用繁體中文回應。"
reply1 = chat("我想學 Python,從哪裡開始?", system)
print(reply1)
reply2 = chat("你剛才說的第一步,可以再詳細說明嗎?") # Claude 能記得剛才的建議
print(reply2)
注意 reply2 完全沒有重複說「你剛才提到的第一步」是什麼——因為 conversation_history 已經帶著第一輪的內容進去了,Claude 自然能接上。
觀念四:對話歷史的裁剪策略
問題來了:如果用戶聊了一小時、幾十輪對話,messages 陣列會越來越大,最終撞上模型的 context window 上限(claude-haiku-4-5 是 200k tokens,其他主要模型是 1M tokens)。就算沒撞上限,帶太多歷史也會增加每次呼叫的成本。

四種常用策略:
策略一:滑動視窗(最簡單)
只保留最近的 N 輪(user + assistant 各算一輪):
MAX_TURNS = 10 # 保留最近 10 輪
def trim_history_window(history: list, max_turns: int = MAX_TURNS) -> list:
"""只保留最近 max_turns 輪對話(每輪 = user + assistant 各一條)"""
max_messages = max_turns * 2
if len(history) > max_messages:
return history[-max_messages:]
return history
策略二:摘要壓縮(平衡型,推薦)
超過一定輪數時,先請 Claude 把舊對話濃縮成摘要,再繼續:
def compress_history(history: list, client, model: str, system: str) -> list:
"""把舊的對話歷史壓縮成摘要,保留最近 4 條訊息"""
if len(history) <= 8:
return history
# 要壓縮的舊歷史(扣掉最近 4 條)
old_history = history[:-4]
recent_history = history[-4:]
# 請 Claude 幫忙做摘要
summary_prompt = "請用繁體中文,用 3-5 句話摘要以下對話的關鍵資訊,重點保留重要事實和用戶的需求:\n\n"
for msg in old_history:
summary_prompt += f"{msg['role'].upper()}: {msg['content']}\n"
summary_response = client.messages.create(
model=model,
max_tokens=512,
messages=[{"role": "user", "content": summary_prompt}]
)
summary = summary_response.content[0].text
# 用摘要取代舊歷史
compressed = [
{"role": "user", "content": f"[對話摘要] {summary}"},
{"role": "assistant", "content": "了解,我會根據上面的摘要繼續協助你。"}
] + recent_history
return compressed
策略三:Token 計數門檻(最精確)
根據實際 token 用量決定是否裁剪。Anthropic Python SDK 提供 count_tokens():
def trim_by_token_count(history: list, client, model: str,
system: str, threshold: int = 50_000) -> list:
"""超過 threshold tokens 就從頭裁掉最舊的一輪"""
while len(history) > 2:
token_count = client.messages.count_tokens(
model=model,
system=system,
messages=history
)
if token_count.input_tokens <= threshold:
break
# 裁掉最舊一輪(user + assistant 各一條)
history = history[2:]
return history
策略四:直接重置(最粗暴,適合明確換話題)
def reset_conversation():
"""話題切換時直接清空歷史"""
global conversation_history
conversation_history = []
print("[對話已重置]")
實務上最常見的做法是:滑動視窗 + 偶爾重置。摘要壓縮雖然效果最好,但多一次 API 呼叫有成本,要評估是否值得。
觀念五:temperature 與其他參數調整
temperature、top_p、max_tokens 是三個最常用的生成參數。在搞清楚 system prompt 和 messages 之後,這些是你下一步的調整點。

temperature:創意度控制
範圍 0.0–1.0,預設 1.0:
# 嚴格精確:技術文件、程式碼生成、資料提取
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
temperature=0.0, # 幾乎確定性輸出
messages=[...]
)
# 均衡創意:一般對話、問答
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
temperature=0.7, # 推薦的均衡值
messages=[...]
)
# 高創意:腦力激盪、故事生成
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
temperature=1.0, # 預設值,最有創意但也最不穩定
messages=[...]
)
max_tokens:輸出長度上限
不是「要求 Claude 輸出多少字」,而是「最多允許它輸出多少 token」。設太低會導致回應被截斷(stop_reason 會是 max_tokens 而不是 end_turn)。
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=256, # 適合簡短回應
messages=[...]
)
# 檢查是否被截斷
if response.stop_reason == "max_tokens":
print("警告:回應被截斷,考慮增加 max_tokens")
各模型的 max output 上限不同:Haiku 4.5 與 Sonnet 4.6 都是 64k tokens,Opus 4.8 最高可到 128k(超過約 16k 建議改用串流,避免 SDK HTTP timeout)。實務上大多數場景設 1024–4096 就夠了,除非你在要求長篇文章或程式碼。
手把手實戰:有人設的 CLI 對話程式
把上面所有觀念組合起來,做一個完整可互動的 CLI 程式。這個程式會:
- 有一個固定人設(嚴格的技術文件助理)
- 維護對話歷史
- 超過 10 輪自動用滑動視窗裁剪
- 輸入
exit結束,輸入reset清空歷史
建立專案結構
先確認目錄和 .env 都準備好了(延續第 1 課的設定):
mkdir capi-03-chatbot
cd capi-03-chatbot
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install anthropic python-dotenv
建立 .env:
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxx
確認 .gitignore 裡有 .env。
設計 system prompt
建立 system_prompt.py,把人設獨立管理:
# system_prompt.py
PERSONA_NAME = "文件助理 Kai"
SYSTEM_PROMPT = f"""你是「{PERSONA_NAME}」,一位技術文件顧問。
你有十年 API 文件撰寫經驗,只用繁體中文回應,語氣專業但友善。
【行為規範】
- 問題不清晰時,優先提問而不是假設
- 不回答與技術文件無關的問題;超出範疇時說:「超出服務範疇。」
【輸出格式】
- 短回應:直接回答
- 長回應(超過 5 點):用編號清單
- 程式碼:一定加語言標記與說明"""
```</div>
<div class="step"><h4>實作對話主程式</h4>
建立 `chatbot.py`:
```python
# chatbot.py
import os
from anthropic import Anthropic
from dotenv import load_dotenv
from system_prompt import SYSTEM_PROMPT, PERSONA_NAME
load_dotenv()
client = Anthropic()
MODEL = "claude-sonnet-4-6"
MAX_TURNS = 10 # 滑動視窗大小
def trim_history(history: list) -> list:
"""超過 MAX_TURNS 輪就裁掉最舊的一輪"""
max_messages = MAX_TURNS * 2 # 每輪 = user + assistant
if len(history) > max_messages:
trimmed = len(history) - max_messages
print(f"\n[trimmed: removed {trimmed // 2} oldest turns]\n")
return history[-max_messages:]
return history
def chat(history: list, user_input: str) -> tuple[str, list]:
"""送出一輪對話,回傳 (回應文字, 更新後的歷史)"""
history.append({"role": "user", "content": user_input})
response = client.messages.create(
model=MODEL,
max_tokens=2048,
temperature=0.7,
system=SYSTEM_PROMPT,
messages=history
)
reply = response.content[0].text
history.append({"role": "assistant", "content": reply})
# 裁剪歷史(在加入本輪之後才裁)
history = trim_history(history)
return reply, history
def main():
history = []
print(f"{'='*50}")
print(f" {PERSONA_NAME} | model: {MODEL}")
print(f" 'reset' = clear history | 'exit' = quit")
print(f"{'='*50}\n")
while True:
try:
user_input = input("你: ").strip()
except (EOFError, KeyboardInterrupt):
print("\n\n再見!")
break
if not user_input:
continue
if user_input.lower() == "exit":
print("再見!")
break
if user_input.lower() == "reset":
history = []
print(f"\n[history cleared]\n")
continue
reply, history = chat(history, user_input)
# 顯示目前歷史長度
turns = len(history) // 2
print(f"\n{PERSONA_NAME} (第 {turns} 輪):\n{reply}\n")
if __name__ == "__main__":
main()
```</div>
<div class="step"><h4>執行並測試多輪記憶</h4>
```bash
python chatbot.py
試著連續問幾個有關聯的問題,確認 Claude 能記住之前的內容:
你: 我要寫一個 REST API 的說明文件,用什麼結構比較好?
Kai (第 1 輪): ...(建議了 OpenAPI spec、端點說明、範例請求等)
你: 你剛才提到的第一個建議,可以給我一個範本嗎?
Kai (第 2 輪): ...(會正確接上第一輪說的「第一個建議」,而不是問「你說的是哪個?」)
你: 用 Python 還是 YAML 寫比較好?
Kai (第 3 輪): ...(還是在 OpenAPI 這個脈絡裡回應)
聊超過 10 輪後,會看到 [trimmed: removed N oldest turns] 這行裁剪提示(對應 trim_history 印出的訊息)。
JavaScript 版本參考
如果你用 Node.js,核心邏輯是一樣的,只是語法改變:
// chatbot.js
import Anthropic from '@anthropic-ai/sdk';
import * as readline from 'readline';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const MODEL = 'claude-sonnet-4-6';
const MAX_TURNS = 10;
const SYSTEM_PROMPT = `你是「文件助理 Kai」,一位專業的技術文件顧問。
你只用繁體中文回應,語氣專業但友善。
每次回應前先確認你理解了用戶的需求。`;
let conversationHistory = [];
function trimHistory(history) {
const maxMessages = MAX_TURNS * 2;
if (history.length > maxMessages) {
console.log(`\n[history trimmed]\n`);
return history.slice(-maxMessages);
}
return history;
}
async function chat(userInput) {
conversationHistory.push({ role: 'user', content: userInput });
const response = await client.messages.create({
model: MODEL,
max_tokens: 2048,
temperature: 0.7,
system: SYSTEM_PROMPT,
messages: conversationHistory
});
const reply = response.content[0].text;
conversationHistory.push({ role: 'assistant', content: reply });
conversationHistory = trimHistory(conversationHistory);
return reply;
}
// readline 互動迴圈省略(與 Python 版邏輯相同)
```</div>
</div>
## 常見坑
**坑 1:messages 陣列裡 role 沒有交替,收到 400 錯誤**
```text
anthropic.BadRequestError: Error code: 400 -
{'type': 'error', 'error': {'type': 'invalid_request_error',
'message': 'messages: roles must alternate between "user" and "assistant",
but found two consecutive "user" roles'}}
這是最常見的新手錯誤。原因通常是:你的程式在某個地方連續 append 了兩條 user 訊息(例如循環邏輯寫錯、或者 reset 指令後沒清空就繼續送)。解法:加一個防護:
def safe_append(history: list, role: str, content: str) -> list:
"""防止連續出現相同 role"""
if history and history[-1]["role"] == role:
raise ValueError(f"不能連續加兩條 {role} 訊息!目前歷史最後是 {history[-1]['role']}")
history.append({"role": role, "content": content})
return history
坑 2:system prompt 改了但行為沒變
你修改了 SYSTEM_PROMPT 的字串,重跑程式,Claude 卻還是按舊規則行事。常見原因有兩個:第一,你的 history 列表沒有清空——舊對話裡 assistant 的行為模式已經設定了脈絡,Claude 可能被先前的對話「引導」走。解法:每次修改 system prompt 後記得 history = [] 重新開始。第二,如果你用了 Prompt Caching(第 7 課的主題),system prompt 可能被快取住,需要等快取失效或手動讓快取過期。
坑 3:回應被截斷,只有半句話
# 回應最後突然沒了句點,看起來像這樣:
# "...建議你使用 OpenAPI 3.1 規範,因為它支援更豐富的 schema 定義,例如"
查一下 response.stop_reason——如果是 "max_tokens" 而不是 "end_turn",代表你設的 max_tokens 太小,Claude 話說到一半就被強制截斷。解法很簡單:把 max_tokens 調大。一般對話建議至少 1024,需要長回應的場景給 4096 或更高。
if response.stop_reason == "max_tokens":
# 可以選擇自動重試,帶入上半段繼續生成
conversation_history.append({
"role": "assistant",
"content": response.content[0].text # 先把截斷的部分存起來
})
conversation_history.append({
"role": "user",
"content": "請繼續"
})
# 再發一次請求...
坑 4:temperature=0 不等於完全確定性輸出
Claude 在 temperature=0 時輸出高度一致,但底層採樣機制偶爾仍可能出現細微差異。需要嚴格可重現輸出的場景(測試/評估),應搭配固定 prompt 版本控管,不能單純依賴 temperature=0。
作業
- 跑起這堂課的
chatbot.py,連續對話至少 15 輪,觀察第 10 輪後觸發裁剪時的[trimmed: ...]提示是否正確出現。 - 修改
SYSTEM_PROMPT,換成一個你實際用得上的人設(例如:旅遊建議助理、程式碼 Debug 機器人、英文寫作修改助理)——把三板斧都用上:角色定義、行為邊界、輸出格式。 - 把裁剪策略從「滑動視窗」改成「摘要壓縮」(本課
compress_history函式),對比兩種策略在長對話後 Claude 對早期對話內容的記憶品質有什麼差別。 - 進階選做:在
chatbot.py加上--model命令列參數,讓使用者在啟動時指定要用哪個模型(Haiku/Sonnet/Opus),感受速度和回應品質的差異。
下一課預告
現在你的 CLI chatbot 能流暢多輪對話了,但有一個痛點:Claude 的回應是自由格式的文字——如果你想把它的輸出餵進資料庫、或傳給另一支程式解析,你必須自己寫複雜的字串解析邏輯,而且 Claude 偶爾會不按格式出牌導致解析失敗。
第 4 課解決這件事。Anthropic 在 2025 年 11 月推出的 Structured Outputs 讓你直接用 JSON Schema(Python 用 Pydantic,JS 用 Zod)定義你要的輸出結構,Claude 會被約束在那個 schema 裡生成——不會多欄位、不會少欄位、不會型別錯誤。告別手寫 regex 解析 Claude 輸出的日子。