跳到主要內容

【2026 最新】deepseek-recipe 教學:不用 GPU,跑通三種 API 與串流工具回合

最後更新: ·
deepseek-recipe 教學:三種 API 轉換與串流工具回合

你把模型接成「相容 API」,第一輪收到工具名稱,第二輪卻接不上結果;串流顯示 completed,參數還是半截。這篇 deepseek-recipe 教學帶你在沒有 GPU、沒有模型權重與 API Key 的情況下,先把三種格式的接線拆開驗清楚。

概念從零講起;動手段落適合已有 Python 3.10 以上、會開終端機與儲存文字檔的讀者。你會得到同一份假庫存查詢的三格式往返紀錄、可閱讀的 Conversation 與 prompt,以及切片和結尾測例。完全沒寫過程式,也可以先讀每段的白話解釋,再照順序複製。

本文於 2026 年 10 月 22 日核對官方程式,固定 Python 套件 deepseek-recipe==0.2.0與 repository 提交 935817c。AlphaLab 在 Python 3.12.13/macOS arm64 跑通下列 Mock 練習與 58 項官方 Python 測試;這些結果檢查轉換與解析,不量模型回答品質或推論速度。

先說結論:協定接通=翻譯+解析+呼叫端責任

deepseek-recipe 像會翻三種訂單的櫃台:把訂單翻成共同格式,再把廚房回報翻回去。它提供 Rust 函式庫與 Python bindings(讓 Python 呼叫底層程式的接線)。依官方 README,模型推論、真正執行工具,以及 HTTP 傳輸由呼叫端提供。裝好翻譯櫃台,還需要有人查庫存、保存對話和處理失敗。

如果你要先使用託管模型,適合讀 DeepSeek V4.1 Flash 遷移教學;這篇則把鏡頭移到轉換函式庫內部。先用固定輸出排除模型變動,才容易看出接線在哪一層出錯。

一、deepseek-recipe 教學先認五個零件

  • ① 請求:「三種訂單表」。Messages、Chat Completions、Responses 各有自己的工具欄位;端點名字相似,不能直接交換整份 JSON。
  • ② Conversation:「共同備料單」。轉換後,角色、內容與工具定義進入共用表示。它是資料結構,與雲端服務替你保存的 session 是兩件事。
  • ③ Prompt:「送給模型的完整工作單」。Encoding 將 Conversation 渲染成 DeepSeek 格式。看見 prompt 是文字準備完成;本練習沒有載入模型。
  • ④ Backend:「廚房」。本篇用 Mock(固定假回覆)交出工具標記或答案;之後換真實推論,由你提供 backend。
  • ⑤ Processor:「回報翻譯員」。StreamProcessor逐段處理輸出,再交給對應格式的 response 累積器。工具真正執行之前,應用程式仍要驗參數與權限。

這也解釋 Agent Harness的角色:模型之外的執行層負責狀態、工具與停止。若要把本篇練習變成完整 Agent,可再接 最小 Harness 實作。

二、先安裝固定版本,印出 Conversation 與 prompt

為什麼先固定版本?因為不固定套件,今天的輸出可能對不上明天的範例。以下以 macOS/Linux 為例,在自己的練習目錄操作;python3 --version先確認至少 3.10。

git clone https://github.com/deepseek-ai/deepseek-recipe.git
cd deepseek-recipe
git checkout 935817c66f5d6e64838f419692545f06787e485f
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --only-binary=deepseek-recipe deepseek-recipe==0.2.0 fastapi==0.159.1 uvicorn==0.54.0 httpx==0.28.1 pytest==9.1.1

這條路優先使用已編譯 wheel,避免在練習中臨時編譯原生依賴。若你的 Python/作業系統沒有對應 wheel,安裝會停止;官方原生建置指南另列 Rust、OpenCV 與 Clang 條件,先判斷是否已有環境,再選那條路。這不是要求你為了印 prompt 就換一部 GPU 主機。

把下面內容存成 inspect_prompt.py,執行 python inspect_prompt.py。本例沿用函式庫接受的模型名稱;這裡的字串選擇編碼規則,沒有向線上模型發出請求。

from deepseek_recipe import ChatCompletionRequest, ConversionOptions, DeepseekV41Encoding
request = ChatCompletionRequest({
    "model": "DeepSeek-V4.1-Flash",
    "messages": [{"role": "user", "content": "查茶杯庫存"}],
})
converted = request.convert(ConversionOptions())
for message in converted.conversation.messages:
    print(message.role, message.content)
print(DeepseekV41Encoding().render_conversation(converted.conversation).prompt)

驗收方式很直接:共同資料裡有 user 與「查茶杯庫存」,渲染後也看得到這句話及特殊標記。先不要刪掉你覺得奇怪的標記;它們屬於編碼格式。接著執行 python -m pytest deepseek-recipe-python/tests server-py/tests -q,本次為 58 passed。這一步驗官方涵蓋的案例,下一步再加自己的工具題。

三、deepseek-recipe 教學實作:同一個假工具走完三種格式

我們只查一個自建商品「茶杯🦀」,假工具 stock固定回傳字串 7。先收到工具要求、核對名稱和參數、把結果帶回完整歷史,再取得「庫存是 7。」。這是兩次請求的一個工具回合,沒有連資料庫,也沒有讓模型自行猜庫存。

第一步,在 repository 根目錄建立 lab_backend.py。它使用官方 create_app(infer=…) 接點;DSML 是這個解析器辨識工具呼叫的標記文字。本例固定輸出這些字,專門測翻譯流程。

from main import create_app
from deepseek_recipe import InferenceChunk, InferenceFinishReason, SpecialToken

TOOL = '''<|DSML|tool_calls>
<|DSML|invoke name="stock">
<|DSML|parameter name="sku" string="true">茶杯🦀</|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>'''

async def infer(request):
    yield InferenceChunk.ready()
    if request.prompt.endswith(SpecialToken.ThinkingStart.default_str()):
        yield InferenceChunk.text(SpecialToken.ThinkingEnd.default_str())
    last = request.conversation_request.conversation.messages[-1]
    text = '庫存是 7。' if last.role == 'tool' else TOOL
    yield InferenceChunk.text(text, token_count=1)
    yield InferenceChunk.finish(InferenceFinishReason.Stop)

app = create_app(infer=infer)

白話來說,假廚房第一次交出工具訂單,看到最後一筆是工具結果才回固定答案。它刻意省略真實模型連線、資料庫查詢與正式的工具派送器;正式服務要依工具名稱白名單、JSON Schema、呼叫 ID 與權限逐項放行,不能照這個固定分支直接接寫入功能。官方自訂推論說明列出 backend 的接線契約。

deepseek-recipe 教學:三種格式的工具結束狀態與結果識別欄位
本次固定 Mock 的第一輪結果。Responses 的 completed 是回應狀態,仍需讀 output 裡的 function_call。

第二步,建立 roundtrip.py,貼上以下程式。它在程序內使用官方 FastAPI app 測試 HTTP 介面,不必另開網路監聽。tool_info把三種回報整理成同一組「ID、名稱、參數」,先嚴格核對,再回傳假資料。

import json
from fastapi.testclient import TestClient
from deepseek_recipe import (
    ChatCompletionRequest, ChatCompletionResponse,
    MessagesRequest, MessagesResponse, ResponsesRequest, ResponsesResponse,
    ConversionOptions, InferenceChunk, InferenceFinishReason, StreamProcessor,
)
from lab_backend import app, TOOL

MODEL = 'DeepSeek-V4.1-Flash'
QUESTION = {'role': 'user', 'content': '查茶杯🦀庫存'}
SCHEMA = {'type': 'object', 'properties': {'sku': {'type': 'string'}},
          'required': ['sku']}
CASES = [
    ('chat', '/v1/chat/completions', ChatCompletionRequest, ChatCompletionResponse,
     {'messages': [QUESTION], 'thinking': {'type': 'disabled'},
      'tools': [{'type': 'function', 'function': {'name': 'stock', 'parameters': SCHEMA}}]}),
    ('responses', '/v1/responses', ResponsesRequest, ResponsesResponse,
     {'input': [QUESTION], 'reasoning': {'effort': 'none'},
      'tools': [{'type': 'function', 'name': 'stock', 'parameters': SCHEMA}]}),
    ('messages', '/v1/messages', MessagesRequest, MessagesResponse,
     {'messages': [QUESTION], 'max_tokens': 128, 'thinking': {'type': 'disabled'},
      'tools': [{'name': 'stock', 'input_schema': SCHEMA}]}),
]

def tool_info(kind, body):
    if kind == 'chat':
        c = body['choices'][0]['message']['tool_calls'][0]
        return c['id'], c['function']['name'], json.loads(c['function']['arguments'])
    if kind == 'responses':
        c = next(x for x in body['output'] if x['type'] == 'function_call')
        return c['call_id'], c['name'], json.loads(c['arguments'])
    c = next(x for x in body['content'] if x['type'] == 'tool_use')
    return c['id'], c['name'], c['input']

def parse(Req, Resp, body, pieces, explicit=True):
    c = Req(body).convert(ConversionOptions())
    p = StreamProcessor(Req.chunk_generator(c, 'fixed', MODEL), c.parsing_options)
    result = Resp('fixed', MODEL, 0, 0, 0)
    chunks = [InferenceChunk.ready()]
    chunks += [InferenceChunk.text(s, token_count=1) for s in pieces]
    if explicit:
        chunks += [InferenceChunk.finish(InferenceFinishReason.Stop)]
    try:
        for chunk in chunks:
            for event in p.push(chunk):
                result.accumulate(event)
            if p.finished:
                break
        for event in p.finish():
            result.accumulate(event)
        return json.loads(result.to_json())
    finally:
        p.close()

with TestClient(app) as client:
    for kind, path, Req, Resp, payload in CASES:
        body = {'model': MODEL, 'stream': False, **payload}
        first = client.post(path, json=body)
        assert first.status_code == 200, first.text
        answer = first.json()
        call_id, name, args = tool_info(kind, answer)
        assert name == 'stock' and args == {'sku': '茶杯🦀'}
        # A read-only fake tool: only this exact input is allowed.
        value = '7'
        if kind == 'chat':
            body['messages'] += [0]['message'],
                {'role': 'tool', 'tool_call_id': call_id, 'content': value}]
        elif kind == 'responses':
            body['input'] += answer['output'] + [
                {'type': 'function_call_output', 'call_id': call_id, 'output': value}]
        else:
            body['messages'] += [{'role': 'assistant', 'content': answer['content']},
                {'role': 'user', 'content': [
                    {'type': 'tool_result', 'tool_use_id': call_id, 'content': value}]}]
        second = client.post(path, json=body)
        assert second.status_code == 200, second.text
        assert '庫存是 7。' in second.text
        print(kind, 'roundtrip PASS')
        # Return to first-turn input before testing parser splits.
        body = {'model': MODEL, 'stream': False, **payload}
        body['input' if kind == 'responses' else 'messages'] = [QUESTION]
        for cut in range(1, len(TOOL)):
            result = parse(Req, Resp, body, [TOOL[:cut], TOOL[cut:]])
            assert tool_info(kind, result)[1:] == ('stock', {'sku': '茶杯🦀'})
        print(kind, len(TOOL) - 1, 'split pairs PASS')
        result = parse(Req, Resp, body, list(TOOL))
        assert tool_info(kind, result)[1:] == ('stock', {'sku': '茶杯🦀'})
        print(kind, 'codepoint chunks PASS')
        partial = parse(Req, Resp, body, [TOOL[:TOOL.index('茶杯') + 1]], explicit=False)
        try:
            _, n, a = tool_info(kind, partial)
            valid = n == 'stock' and set(a) == {'sku'} and a['sku'] == '茶杯🦀'
        except (ValueError, KeyError, TypeError):
            valid = False
        assert not valid
        print(kind, 'partial EOF rejected')

執行 PYTHONPATH=server-py python roundtrip.py。每種格式應各出現 roundtrip PASS、145 split pairs PASS、codepoint chunks PASS與partial EOF rejected。這些是此固定輸入的驗收結果;程式中的 assert 要保留,執行時也不要加 -O。

如果想親眼看 HTTP 串流,另執行 PYTHONPATH=server-py python -m uvicorn lab_backend:app --host 127.0.0.1 --port 7777,再從另一個終端機送出:

curl -N http://127.0.0.1:7777/v1/responses   -H 'Content-Type: application/json'   -d '{"model":"DeepSeek-V4.1-Flash","input":"查茶杯庫存","reasoning":{"effort":"none"},"tools":[{"type":"function","name":"stock","parameters":{"type":"object","properties":{"sku":{"type":"string"}},"required":["sku"]}}],"stream":true}'

你會看到 Responses 的事件流與工具參數片段。官方範例另外提供 /v1/messages與/v1/chat/completions;API 範例說明三者的 JSON/SSE 模式。依部署範圍,範例沒有驗證使用者身分,圖片下載預設也未過濾私有網路位址。這次只送自建文字、綁定 127.0.0.1;不要把練習埠直接開給公網。

四、串流切片怎麼驗?先分清語意、空白與結尾

把一張訂單撕成幾片,內容理應還是同一張;但「怎麼算紙張成本」可能另有規則。上面的程式把 146 個 Unicode code point 的固定 DSML 字串,在每個內部位置切成兩段,每格式 145 種,再加逐字元切片。三者都保留 stock和{"sku":"茶杯🦀"}。呼叫 ID 每次可能重新產生,應驗本輪對應關係,別要求所有測試的隨機 ID 相等。

字元切片不等於 UTF-8 位元組切片。這裡餵給 InferenceChunk.text的是已解碼 Python 字串;原始網路位元組若把一個中文字拆開,接收層須先連續解碼。官方 Python 測試另有附 tokenizer 的 test_split_character_tokens,驗證多個 token 才組出一個字元的案例;不要把兩層切片混成同一份證據。

想加 JSON 題,可把以下段落接在 roundtrip.py最後,再執行一次。prompt 必須提到 json;切片後用 json.loads比較物件內容,保留原始字串供你查看空白。

body = {"model": MODEL, "messages": [{"role": "user", "content": "Return json"}],
        "thinking": {"type": "disabled"}, "response_format": {"type": "json_object"}}
source = 'prefix {"sku":"茶杯🦀","n":7}   '
outputs = []
for cut in range(1, len(source)):
    result = parse(ChatCompletionRequest, ChatCompletionResponse, body,
                   [source[:cut], source[cut:]])
    text = result["choices"][0]["message"]["content"]
    assert json.loads(text) == {"sku": "茶杯🦀", "n": 7}
    outputs.append(text)
print("json", len(outputs), "split pairs PASS; raw variants", len(set(outputs)))

本次這 28 種切法都得到相同 JSON 物件,原始文字有兩種前置空白數。這符合0.2.0 的 ToSpace 契約:被轉成空白的文字,按涵蓋的來源 chunk 輸出空白。先決定你的契約要比較字串還是物件,別把合法空白差異直接當參數改變。

usage 也要另看。依Processor 的計數文件,包含 stop sequence 的整個來源 chunk 會計入 completion usage,後續 chunk 不再讀取。本次另以人工指定 token_count 的 abcSTOPtail測例,得到相同可見內容 abc,但一塊指定 9、兩塊指定 4+5 時,回報為 9 與 4。這是在驗呼叫端提供的合成計數,不是模型 tokenizer 或託管 API 的費用實測。

deepseek-recipe 教學:明確完成、直接 EOF 與半截工具參數的驗收差別
固定 0.2.0 Mock 測例:終止欄位與工具參數正確性必須分開檢查。

最值得練的是結尾。同一份完整工具文字,接明確 InferenceChunk.finish(Stop)時,Chat 為 tool_calls、Messages 為 tool_use;讓來源直接耗盡,再呼叫 processor.finish(),本次分別變成 stop、end_turn,Responses 仍為 completed。兩種 finish 代表不同層的操作,不能只因名字相近就互換。

更危險的是只送到「茶」就耗盡:Chat 與 Responses 留下不完整 JSON 參數,Messages 的完整回應累積器則得到空物件 {},終止欄位仍可能看似完成。Messages 累積器原始碼可核對解析失敗時的處理。範例因此用必要欄位和固定期望值把它拒絕;真工具還要核對型別、範圍、數量、ID 與自己的上游完成/取消紀錄。

五、兩輪歷史誰保存?先把這一頁完整帶回去

第一輪的工具 ID,像寄物櫃領取單;工具結果必須對回同一張單。roundtrip.py讓 Chat 帶回 assistant tool_calls 與 role:tool;Messages 帶回 assistant tool_use 與 user 裡的 tool_result;Responses 帶回 output 與 function_call_output。第二輪仍含原始問題,不能只送一個孤立的 7。

截至上述提交,Responses 模組文件明列 conversation storage 在支援範圍之外;請求 schema未定義 previous_response_id/store,且說明未知頂層欄位會忽略。回應預設值為 previous_response_id:null、store:false。因此本文走完整歷史重送,不用這些欄位假裝已有 session。這個判斷只涵蓋固定版本的轉換層,不外推到 DeepSeek 託管 API。

若開 thinking,歷史還可能包含 reasoning;應按所選格式保存必要欄位,別只抄可見文字。本篇關閉 thinking 來隔離工具題。把成功、失敗與取消另存,換版本後重播同一批案例,便能接上 Provider 契約測試;要做畫面串流的復原,再看 OpenUI 斷線與重試教學。

把這個回合當成三張收據讀:第一張是使用者問什麼,第二張是 assistant 要求哪個工具與哪些參數,第三張是那個工具實際回了什麼。第二輪再交出三張收據,才有條件檢查回答是否接到同一件事。如果只留最後的自然語言答案,日後遇到錯庫存,就很難分辨是查錯商品、帶錯 ID,還是根本沒執行工具。

練習也可以故意把茶杯換成不存在的商品:本篇固定假工具的驗收必須拒絕,而不是繼續回 7。再把 tool_call_id 換成另一輪的值,確認你的應用程式擋下它;不能因為都是數字 7 就視為相同結果。這兩題是下一步新增的檢查方向,並未計入本文已跑過的切片數。

FAQ:八個直接答案

1. 沒有 GPU,也能完成這篇嗎?

可以。這裡執行轉換、解析與 Mock server,不載入模型權重;先確認 Python 與對應 wheel。

2. Mock 回答了庫存,代表模型查對了嗎?

不代表。庫存 7 與答案都由我們寫死,驗的是資料往返及欄位對應。

3. 三格式通過,等於整套 API 完全相容嗎?

不能這樣推論。本文只測選定的文字與函式工具流程;影像、服務端工具、登入與真正推論各有另外的契約。

4. 看到 completed,就能執行工具嗎?

還不行。先找 function_call,再驗名稱、參數、ID、權限與來源完成紀錄;本次半截輸出也可能出現完成狀態。

5. 每個切片都要是完整 JSON 嗎?

不用。先累積參數片段,到合適邊界再解析;完整物件仍要過必要欄位與型別檢查。

6. 切片不同,usage 也必須相同嗎?

要看契約。這版 Processor 會計入含停止序列的整塊來源計數;本篇人工 token_count 不能拿來估模型費用。

7. 送 previous_response_id 就會記住上一輪嗎?

在本文固定版本不成立。請求未定義此欄位,conversation storage 另列在範圍外;依範例帶回完整歷史。

8. 下一步可以接付款或刪除工具嗎?

先留在唯讀。把白名單、參數驗證、逾時、取消、重複提交去重與執行紀錄接好,再獨立驗收具有副作用的操作。

給新手的三個重點

先用固定假回覆把轉換測清楚,再換成模型;先驗可用參數與歷史對應,再執行工具;先分開記錄終止訊號、JSON 語意與 usage,再判斷哪種差異真的是錯誤。可閱讀的 prompt 與可重播的失敗案例,比一次 HTTP 200 更能幫你定位問題。

接著閱讀

左右滑動查看更多推薦

下一步:先讓一筆半截工具要求被正確拒絕

今天先跑通 roundtrip.py,找到三行 partial EOF rejected,再修改一個工具名稱或刪掉 sku,確認驗收會失敗。記住:協定接通=翻譯+解析+呼叫端責任。如果想把這些零件接成自己的應用,可從 AlphaLab AI 專區或AlphaLab 課程繼續安排實作。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

週日 Weekly 週報+週二、週四 Alpha Signal,每週最多三封。

我們不會 spam,隨時可退訂。已訂閱?管理訂閱偏好(會寄登入連結到你的信箱)

主題篩選與退訂說明

寄送日期以台灣時間為準。主題偏好會篩選每期精選,並非每篇新文章的即時通知;當期符合主題的文章不足時會略過寄送。

未勾選任何主題會收到所有主題的精選,並不會停止寄信。

取消勾選 Alpha Signal 可停止週二、週四短報,仍保留週日週報。若要停止所有電子報,請使用信件底部的「取消訂閱」。