FDE PulseViệc làm FDE đang mở 316Mới đăng 7 ngày qua 10Chủ đề nổi bật: Đào tạo kỹ năng FDE tại Đông Nam Á

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

Gọi thẳng API Claude và OpenAI: tự viết vòng tool use, rồi đối chiếu với Gemini

Trước khi giao mọi thứ cho framework, bạn nên tự đi hết một vòng request, streaming và tool call với Claude và OpenAI, rồi biết Gemini khác ở đâu. Đó là những thứ bạn sẽ phải gỡ lỗi khi ngồi ở văn phòng khách hàng.

Đồ hoạHistory lớn dần qua một vòng tool use với Claude
  1. 1history = [câu hỏi user]Lượt đầu chỉ có tin nhắn user; request gửi kèm tham số tools
  2. 2Model trả stop_reason tool_useResponse chứa block tool_use có id; chưa có câu trả lời cho người dùng
  3. 3Append lượt assistantĐưa nguyên resp.content vào history với role assistant, giữ id của tool_use
  4. 4Chạy hàm, append tool_resultCode gọi get_order_status rồi thêm role user chứa tool_result kèm tool_use_id
  5. 5Gửi lại toàn bộ historyAPI stateless nên request mới mang đủ mọi lượt; lặp tối đa 5 vòng

Mỗi vòng tool use thêm hai lượt vào history và bạn phải gửi lại tất cả, vì Messages API không tự nhớ.

Đồ hoạ: FDE Times

Tóm tắt nhanh

  • Claude Messages API là stateless: lượt nào bạn cũng phải gửi lại toàn bộ lịch sử, và nhớ trả tool_result kèm tool_use_id
  • OpenAI Responses API trả function_call kèm call_id trong mảng output; bạn gửi kết quả về bằng function_call_output
  • Với dự án mới, Gemini khuyên dùng Interactions API, còn generateContent bị coi là legacy; ở cả ba nhà cung cấp, model chỉ đề xuất lời gọi, code của bạn mới là bên chạy hàm
Chia sẻLinkedInFacebookX

Chỉ một tham số cũng đủ làm hỏng buổi demo. Trên các model Claude mới, nếu đặt temperature, top_p hay top_k khác giá trị mặc định, API sẽ trả lỗi 400. Từ dòng 4.6 trở lên, kỹ thuật prefill cũng bị từ chối, nên đoạn code chép từ một bài blog cũ có thể gãy ngay trước mặt khách hàng.

Framework che bớt những chi tiết này, nhưng không che mãi được. Khi bạn làm FDE, hệ thống của khách hàng có thể hỏng ở tầng thấp nhất: một header sai, một id không khớp, một lượt hội thoại bị mất. Cách nhanh nhất để đọc được những lỗi đó là tự tay gọi API thô, ít nhất một lần.

Bài này dẫn bạn dựng một script nhỏ có tool get_order_status(order_id), tra trạng thái đơn hàng từ một hàm giả lập. Bạn sẽ đi trọn vòng request, streaming và tool use với Claude và OpenAI, rồi đối chiếu cách Gemini xử lý cùng bài toán. Bạn cần Python, API key đặt trong biến môi trường và hai SDK anthropic, openai.

Code dưới đây được rút gọn để dễ theo dõi, chưa có xử lý lỗi hay retry. Tên model nằm trong hai biến MODEL và OA_MODEL; bạn điền tên model hiện hành theo docs của từng hãng.

Bước 1: vì sao nên bắt đầu bằng curl?

Bạn phải nhìn thấy request thô trước khi để SDK bọc nó lại. Claude Messages API nhận POST /v1/messages, xác thực bằng header x-api-key và cần thêm header anthropic-version. Trong ví dụ của Anthropic, max_tokens và mảng messages là hai trường bắt buộc.

curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model": "'"$CLAUDE_MODEL"'", "max_tokens": 512,
       "messages": [{"role": "user", "content": "Chào bạn"}]}'

Kiểm tra: bạn phải nhận về một JSON có nội dung trả lời. Giờ hãy gửi lượt thứ hai mà chỉ có câu hỏi mới. Model sẽ không nhớ gì về lượt trước, vì Messages API là stateless: lượt nào bạn cũng phải gửi lại toàn bộ lịch sử hội thoại.

Vì thế, khi khách hàng phàn nàn rằng model “quên” lượt trước, đây là chỗ đầu tiên nên kiểm tra.

Bước 2: streaming phải hiện chữ dần dần

Người dùng ngồi nhìn màn hình trắng mười giây sẽ nghĩ hệ thống đã treo. Với Claude, bạn đặt "stream": true để nhận phản hồi từng phần qua server-sent events. SDK Python có sẵn helper text_stream nên bạn không phải tự parse event.

import anthropic
client = anthropic.Anthropic()
history = [{"role": "user", "content": "Giải thích SSE trong 3 câu"}]

with client.messages.stream(model=MODEL, max_tokens=512,
                            messages=history) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)

Kiểm tra: chữ phải hiện ra dần trên terminal. Nếu cả đoạn văn hiện ra cùng một lúc, hãy xem giữa máy bạn và API có proxy hay gateway nào gom response lại trước khi trả về hay không. Ở mạng nội bộ của khách hàng, đó là một câu hỏi đáng đặt ra sớm.

Bước 3: model chỉ đề xuất, code của bạn mới chạy hàm

Đây là ý quan trọng nhất của cả bài. Khi Claude muốn dùng một client tool, nó trả về stop_reason: "tool_use" cùng một hoặc nhiều block tool_use. Code của bạn chạy hàm, rồi gửi kết quả về trong block tool_result; server tools thì khác, chúng chạy trên hạ tầng của Anthropic.

Trước hết, viết một hàm giả lập trả về chuỗi, khai báo tool và một hàm gọi API dùng chung:

def get_order_status(order_id): return f"Đơn {order_id}: đang giao"  # dữ liệu giả

tools = [{"name": "get_order_status",
          "description": "Tra trạng thái đơn hàng theo mã",
          "input_schema": {"type": "object",
              "properties": {"order_id": {"type": "string"}},
              "required": ["order_id"]}}]

def ask():
    return client.messages.create(model=MODEL, max_tokens=512,
                                  tools=tools, messages=history)

Rồi đặt câu hỏi vào history và viết vòng lặp, có giới hạn số vòng để tránh lặp vô tận:

history = [{"role": "user", "content": "Đơn A123 tới đâu rồi?"}]
resp = ask()
for _ in range(5):
    if resp.stop_reason != "tool_use":
        break
    history.append({"role": "assistant", "content": resp.content})
    results = [{"type": "tool_result", "tool_use_id": b.id,
                "content": get_order_status(**b.input)}
               for b in resp.content if b.type == "tool_use"]
    history.append({"role": "user", "content": results})
    resp = ask()

Kiểm tra: thêm một dòng log ngay trong get_order_status. Nếu dòng log hiện ra và câu trả lời cuối có chữ “đang giao” do hàm trả về, vòng lặp đã chạy đúng.

Cũng cần nhớ là tool không miễn phí: API tự chèn một system prompt ẩn để bật tool use, và tham số tools cùng các block tool_use, tool_result đều tính token.

Bước 4: OpenAI Responses đổi tên, giữ nguyên logic

Sang Responses API, vòng lặp vẫn như cũ, chỉ có tên gọi thay đổi. Lời gọi hàm nằm trong mảng output, dưới dạng một item có type bằng function_call và kèm theo call_id. Bạn trả kết quả về bằng một item function_call_output mang đúng call_id đó.

Khai báo tool theo định dạng của OpenAI, dùng lại schema và hàm giả lập ở bước 3:

import json
from openai import OpenAI
oa = OpenAI()
oa_tools = [{"type": "function", "name": "get_order_status",
             "description": "Tra trạng thái đơn hàng theo mã",
             "parameters": tools[0]["input_schema"]}]
inputs = [{"role": "user", "content": "Đơn A123 tới đâu rồi?"}]

Rồi chạy một vòng gọi hàm và gửi kết quả về:

resp = oa.responses.create(model=OA_MODEL, input=inputs, tools=oa_tools)
for item in resp.output:
    if item.type == "function_call":
        args = json.loads(item.arguments)
        inputs.append(item)
        inputs.append({"type": "function_call_output",
                       "call_id": item.call_id,
                       "output": get_order_status(**args)})
resp = oa.responses.create(model=OA_MODEL, input=inputs, tools=oa_tools)

Lưu ý đoạn này chỉ xử lý đúng một vòng gọi hàm, khác với vòng lặp có giới hạn ở bước 3. Nếu model đề xuất thêm lời gọi sau khi nhận kết quả, bạn cần bọc nó trong một vòng for tương tự, dừng khi output không còn item function_call nào.

Streaming trên Responses API cũng bật bằng stream=True, nhưng thứ bạn nhận về là các semantic event, mỗi event mang một kiểu riêng. Trước khi viết bất kỳ logic xử lý nào, hãy in hết event.type ra để xem luồng sự kiện trông thế nào:

for event in oa.responses.create(model=OA_MODEL, input=inputs,
                                 tools=oa_tools, stream=True):
    print(event.type)

Kiểm tra: bạn sẽ thấy một chuỗi event có tên riêng cho từng loại. Hãy chọn đúng event chứa đoạn text mới, đối chiếu tên với docs, đừng đoán.

Bước 5: Gemini đang đổi API, đừng copy code cũ

Với Gemini, cái bẫy nằm ở chỗ bạn chọn API nào. Docs hiện tại khuyên dự án mới dùng Interactions API và xếp generateContent vào nhóm legacy, nên các tutorial dùng generateContent trên mạng có thể đã lỗi thời. Khi gọi qua REST, key được truyền bằng header x-goog-api-key.

# Khung rút gọn: endpoint và body lấy từ trang Interactions API
curl "$GEMINI_ENDPOINT" \
  -H "x-goog-api-key: $GEMINI_API_KEY" \
  -H "content-type: application/json" \
  -d @request.json

Bài này không viết sẵn vòng tool use cho Gemini, vì cấu trúc request của Interactions API bạn nên lấy thẳng từ docs. Nguyên tắc thì không đổi: Gemini không tự chạy hàm, nó chỉ đề xuất một lời gọi, còn ứng dụng của bạn chạy hàm và gửi kết quả về.

Mặc định, response chỉ trả về khi model sinh xong, nên muốn nhận từng phần thì bạn phải bật streaming.

Bài tập mở rộng: dựng lại vòng get_order_status trên Interactions API theo docs, với cùng câu hỏi về đơn A123, rồi so cách Gemini đề xuất lời gọi hàm với hai nhà cung cấp còn lại.

Ba API, một bảng đối chiếu

Claude Messages OpenAI Responses Gemini
Streaming stream: true, SSE stream=True, semantic events Bật streaming để nhận từng phần
Tín hiệu gọi tool stop_reason: "tool_use" item function_call trong output Model đề xuất lời gọi hàm
Trả kết quả tool_result + tool_use_id function_call_output + call_id App gửi kết quả về
Lịch sử Client gửi lại toàn bộ Có thể giữ state trên server Theo API bạn chọn

Những lỗi sẽ gặp ở dự án thật

Hay gặp nhất là cấu hình sampling cũ: config của khách hàng còn temperature: 0.2 từ thời model trước thì model Claude mới sẽ trả 400. Kế đến là quên id, khi tool_result thiếu tool_use_id hoặc function_call_output thiếu call_id, model không biết kết quả thuộc lời gọi nào. Rồi còn chuyện nhồi vài chục tool vào mọi request và ngạc nhiên vì hoá đơn token tăng vọt.

Ngoài ra còn một quyết định kiến trúc mà bạn nên nói rõ với khách hàng. Anthropic định vị Messages API cho các agent loop tự viết, cần kiểm soát chi tiết, trong khi OpenAI cho biết Responses API giữ được reasoning giữa các lượt, điều Chat Completions không làm được, và chạy hosted tools ngay phía server.

Simon Willison cũng chỉ ra rằng Responses có thể quản lý state hội thoại trên server thay cho bạn.

Nếu bạn ở vị trí FDE, cách làm hợp lý là hỏi khách hàng hai câu ngay từ buổi đầu: lịch sử hội thoại được phép nằm ở đâu, và ai sẽ là người chạy tool.

Một ngân hàng muốn tự giữ toàn bộ log trong hệ thống của mình sẽ hợp với mô hình stateless hơn, còn một startup cần ra sản phẩm nhanh có thể muốn giao bớt phần quản lý state cho server.

Biến bài tập thành bằng chứng trong CV

Một câu “có kinh nghiệm LLM” trong CV khó chứng minh được rằng bạn hiểu tầng nằm dưới framework, trong khi đó lại là tầng bạn phải gỡ khi framework hỏng ở chỗ khách hàng. Hãy đẩy script này lên GitHub với hai adapter Claude và OpenAI chung một interface call_tool, kèm README ghi lại từng lỗi 400 bạn gặp và cách sửa.

Trong CV, hãy ghi cụ thể rằng bạn đã tự viết vòng tool use trên Claude Messages và OpenAI Responses, và nếu đã làm bài tập mở rộng thì thêm cả Gemini Interactions API.

Khi đọc JD, hãy để ý các cụm như “integrate with multiple model providers” hay “build agent loops”. Đó chính là bài tập bạn vừa làm, và giờ bạn có thể kể lại nó bằng các trường dữ liệu cụ thể chứ không chỉ bằng tên framework.

10 nguồn
Đọc tiếp trên lộ trình · Chặng 3: AI ứng dụngZero-shot, few-shot, CoT hay ReAct: thử cả bốn trên một bộ ticket để biết lúc nào cần kỹ thuật nặng hơnModel đời mới không làm prompt engineering lỗi thời. Chúng chỉ khiến thói quen bắt đầu bằng kỹ thuật nặng nhất trở nên tốn kém.