精華筆記

· @aihub.tw

Claude API 開發實戰

System Prompt 與多輪對話

System Prompt 與多輪對話

上一堂課你學會了選模型、估成本——但你的程式和 Claude 說話的方式,還停在「送一句問題、拿一個答案」的層次。現實的產品不是這樣的:用戶會接著追問、Claude 需要記住剛才說了什麼、你要讓 Claude 用特定的角色和風格回應。這三件事背後都指向同一個機制:messages 陣列的正確組裝方式,以及 system prompt 的工程方法。

很多人第一次寫多輪對話時會踩到一個很蠢但很常見的坑:「我跟 Claude 說完前三輪了,第四輪為什麼它不記得?」答案是:Claude 的 Messages API 是完全無狀態的。你每次送請求,它都只看你這次帶進去的 messages 陣列——你不帶歷史,它就沒有記憶。這堂課把這個機制從頭說清楚。

這堂課適合誰 適合:已完成第 1、2 課,能成功呼叫 Claude API 並選好模型的開發者(本課程屬工程師專區)。需要基礎:Python 或 JavaScript 基礎(會定義函式、操作 list/array、讀寫檔案即可)。前置課:第 1 課(API 呼叫基礎)、第 2 課(模型選擇)。

這堂學什麼

  • systemuser 角色的本質差異:為什麼 system prompt 不只是「第一句話」
  • System prompt 工程三板斧:角色定義、行為邊界、輸出格式——每種都有具體範本
  • Messages API 的無狀態本質:你要自己帶歷史,不帶就沒記憶
  • 對話歷史越來越長的危機:四種裁剪策略與選擇時機
  • temperaturetop_pmax_tokens 的實際作用與推薦值
  • 手把手實戰:寫一支有人設的互動式 CLI 對話程式(Python 主,附 JS)

觀念一:system 和 user 是兩條不同的指令管道

初學者常把 system prompt 理解為「對話的第一句話」,這個理解是錯的。在 Claude 的眼裡,system 和 user 是兩個完全不同的指令層:

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 要獨立拉出來 你也可以把指令塞進第一條 user 訊息——很多人早期這樣做,但 Anthropic 的建議是始終用獨立的 system 參數。原因:Claude 對 system 層的「服從度」比 user 層更高,用來設定角色和規則更可靠;另外有些功能(例如 Prompt Caching)也要求指令在 system 層才能生效。

觀念二:System Prompt 工程三板斧

一個爛的 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 陣列無狀態架構圖

messages 陣列有兩個硬規則:

  1. role 必須交替出現:user → assistant → user → assistant,不能連續兩個同 role
  2. 第一條必須是 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 與其他參數調整

temperaturetop_pmax_tokens 是三個最常用的生成參數。在搞清楚 system prompt 和 messages 之後,這些是你下一步的調整點。

temperature 參數對照圖

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 就夠了,除非你在要求長篇文章或程式碼。

stop_sequence 陷阱 如果你的輸出格式有特殊結束符號(例如要求 Claude 用 `[END]` 結尾),可以設 `stop_sequences=["[END]"]`。Claude 輸出到那個字串就會停止,且停止符號**不會**出現在回應內容裡。忘記這點會導致解析時找不到結束符。

手把手實戰:有人設的 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。

作業

  1. 跑起這堂課的 chatbot.py,連續對話至少 15 輪,觀察第 10 輪後觸發裁剪時的 [trimmed: ...] 提示是否正確出現。
  2. 修改 SYSTEM_PROMPT,換成一個你實際用得上的人設(例如:旅遊建議助理、程式碼 Debug 機器人、英文寫作修改助理)——把三板斧都用上:角色定義、行為邊界、輸出格式。
  3. 把裁剪策略從「滑動視窗」改成「摘要壓縮」(本課 compress_history 函式),對比兩種策略在長對話後 Claude 對早期對話內容的記憶品質有什麼差別。
  4. 進階選做:在 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 輸出的日子。

#Claude API#System Prompt#多輪對話#Prompt 工程#Python

← 回所有文章