ข้าม​ไป​ยัง​เนื้อหา

จาก raw ไป Microsoft.Extensions.AI

บท​ที่ 4 คุณ​ต่อ​ท่อ tool use ด้วย​มือ​ทุก byte — ส่ง tools เข้าไป อ่าน stop_reason แตก branch เมื่อ​เจอ tool_use รัน getOrder ใน C# ต่อ tool_result กลับ​เข้า list แล้ว​ส่ง​ทั้ง​ก้อน​ไป​ใหม่ ท่อ​นั้น​ทำงาน​ถูก และ​คุณ​ควร​เห็น​มัน​สัก​ครั้ง​เพื่อ​รู้​ว่า​ใต้​ฝากระโปรง​มี​อะไร แต่​ใน project จริง​คุณ​คง​ไม่​อยาก​เขียน JsonDocument.Parse กับ​ไล่ index content[] เอง​ทุก​ครั้ง บท​นี้​เอาการ​เรียก ตัว​เดิม​เป๊ะๆ มา​เขียน​ใหม่​ด้วย​สำนวน .NET ผ่าน abstraction กลาง​ของ Microsoft — แล้ว​ปล่อย​ให้ middleware วน loop ของ tool ให้​เรา​แทน

📦 code ตัวอย่าง

คอร์ส​นี้​ค่อยๆ ประกอบ agent สำหรับ support ลูกค้า​บน domain Order ของบริการฟู้ด​เดลิ​เวอรี จาก repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​ยก​การ​เรียก getOrder จาก​บท 4 มา​เขียน​ใหม่​ด้วย IChatClient โครง wiring ที่​เห็น​เป็น​ชิ้น​ที่ capstone (บท 8) จะ​หยิบ​ไป​ประกอบ​ต่อ​จริง

Microsoft.Extensions.AIMicrosoft.Extensions.AIlibrary ทางการ​ของ .NET (package `Microsoft.Extensions.AI` สาย 10.x GA) ที่​ให้ abstraction กลาง​อย่าง IChatClient สำหรับ​ต่อ​กับ model AI แบบ​ไม่​ผูก​ค่าย พร้อม automatic function invocation และ​เข้า​กับ DI ได้ — ⚠️ ความ​จริง​เรื่อง connector: Microsoft มี connector ทางการ​เฉพาะ OpenAI/Azure OpenAI สำหรับ Claude ยัง​ไม่มี first-party ต้อง​ใช้ package `Anthropic` ทางการ (v10+ สถานะ beta) ผ่าน `AsIChatClient(...)` และ​ห้าม​ลาก​ผ่าน connector ของ OpenAIArchitecture (ย่อ MEAI) คือ​ชุด abstraction กลาง​ของ .NET สำหรับ​คุย​กับ generative AI — วางตัว​แบบ​เดียว​กับ​ที่ ILogger วางตัว​เหนือ logger ทุก​เจ้า คุณ​เขียน code พิง​หน้า interface กลาง แล้ว​สลับ provider ข้าง​ล่าง​ได้​โดย​ไม่​รื้อ logic หัวใจ​ของ​มัน​คือ IChatClientIChatClientinterface กลาง​ใน Microsoft.Extensions.AI สำหรับ​คุย​กับ model แบบ provider-agnostic — method หลัก​ที่​มือใหม่​ใช้​คือ `GetResponseAsync(messages, options)` (และ version streaming) รับ `ChatMessage`/`ChatRole` คืน `ChatResponse` ต่อ middleware เพิ่ม​ได้​ผ่าน `.AsBuilder()` เช่น `.UseFunctionInvocation()` — เป็นการ​เรียก​แบบ​เดียว​กับ API ดิบ​ใน​บท​ก่อน แต่​เขียน​ด้วย​สำนวน .NET ⚠️ อย่า​ตั้ง `ChatOptions.Temperature` กับ Claude รุ่น​ล่าสุด (400)Architecture — interface เดียว​ที่​แทน “ปลายทาง​ที่​รับ​ข้อความ​แล้ว​ตอบ​กลับ” ไม่​ว่า​เบื้องหลัง​จะ​เป็น Claude, model อื่น หรือ​ตัว mock ใน test

method ที่​มือใหม่​แตะ​จริง​มี​แค่​สอง​ตัว:

  • Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage>, ChatOptions? = null, CancellationToken = default) — ส่ง​ประวัติ​ข้อความ​เข้า รับคำ​ตอบ​หนึ่ง​ก้อน​กลับ
  • IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(...) — version stream ที​ละ​ชิ้น (เก็บ​ไว้​ก่อน บท​นี้​ใช้​ตัว​บน)

package หลัก​คือ Microsoft.Extensions.AI version 10.8.0 (GA, เผยแพร่ 2026-07-14) — เป็น​สาย GA แล้ว ไม่ใช่​สาย 9.x-preview เก่า อย่า​อ้าง version preview เพราะ​ชื่อ type บาง​ตัว​ขยับ

ในบท 2–4 MessageMessageหน่วย​หนึ่ง​เทิร์นของ​บทสนทนา ประกอบ​ด้วย role (`user` หรือ `assistant`) กับ content — Messages API รับ `messages[]` ที่​เรียง​สลับ​กัน​ไป และ​ตัว API เป็น​แบบ stateless คือ​ต้อง​ส่ง​ประวัติ​ทั้ง​ชุด​กลับ​ไป​ใหม่​ทุก​ครั้ง หมายเหตุ: ใน API ดิบ​ไม่มี role ชื่อ `system` (มัน​เป็น field ระดับ​บน​สุด​แยก​ต่างหาก) และ​ไม่มี role `tool` — ผล​ของ tool ถูก​ส่ง​กลับ​ใน​ข้อความ role `user`Architecture คือ object ดิบ​ใน JSON ที่​มี role เป็น string ("user" / "assistant") ฝั่ง MEAI ห่อ​สิ่ง​เดียวกัน​นี้​ด้วย type ของ .NET: 1 message คือ new ChatMessage(ChatRole.User, "...") โดย ChatRole เป็น enum ที่​มี​สมาชิก System, User, Assistant, Tool แนวคิด​ไม่​ต่าง​จาก​เดิม​เลย — แค่​ได้ type safety กับ IntelliSense มา​แทน string ดิบ

⚠️ อย่า​ปน 'message' สอง​สำนวน​ของ C#

ใน​โลก C# มีหน้าตา message อยู่ สอง​สำนวน​ที่​ต่าง​กัน และ​ห้าม​จับ​มา​ปน​กัน:

  • official Anthropic SDK (สำนวน native): new MessageCreateParams { System = "...", Messages = [ new() { Role = Role.User, Content = "..." } ] } — persona อยู่​ใน field System แยก​ต่างหาก ใช้ Role.User
  • MEAI (สำนวน IChatClient): new ChatMessage(ChatRole.User, "...") รวม​อยู่​ใน list ChatMessage เดียว และ persona กลาย​เป็น message ที่​มี ChatRole.System นำ​หน้า list ใช้ ChatOptions คุม parameter

ทั้ง​คู่ “ถูก” แต่​คนละ API — code ชิ้น​เดียว​เลือก​ได้​แค่​สำนวน​เดียว บท​นี้​ทั้ง​บท​อยู่​ใน​สำนวน MEAI ส่วน​สำนวน native ของ SDK คือ​ที่​คุณ​เห็น​ใน​เอกสาร prompting ของ​บท 3

ก่อน​ต่อ Claude เข้า​กับ MEAI ต้อง​พูด​กันตรงๆ ข้อ​หนึ่ง เพราะ​มัน​ต่าง​จาก​ที่​คุณ​อาจ​คาด​จาก​ฝั่ง OpenAI:

⚠️ ยัง​ไม่มี connector first-party ของ Microsoft สำหรับ Anthropic

Microsoft ปล่อย connector แบบ first-party ให้​เฉพาะ OpenAI / Azure OpenAI เท่านั้น — ไม่มี package Microsoft.Extensions.AI.Anthropic ที่​เป็น​ทางการ ทาง​ที่​แนะนำ​คือ​ใช้ package Anthropic ตัว​ทางการ (v10+ ซึ่ง​ติด​ป้าย beta) แล้ว​เรียก AsIChatClient(...) เพื่อ​สวม​มัน​เข้า​หน้า IChatClient

พูด​ให้​ชัด: มัน​คือ พื้นที่ beta — pin version ไว้​ให้​แน่น​และ​ลง​วัน​ที่​กำกับ เพราะ minor/patch อาจ​มี breaking change ได้ ต่าง​จาก connector OpenAI ที่​นิ่ง​กว่า และ อย่า ลาก Claude ผ่าน connector ของ OpenAI (เคย​มี​ปัญหา​เรื่อย​มา ดู dotnet/extensions issue #7058) — ต่อ​ผ่าน package Anthropic ของ​มัน​เอง​เท่านั้น

(ทาง​เลือก​ฝั่ง community ก็มี เช่น Anthropic.SDK ของ tghamm แต่​คอร์ส​นี้​ยึด package Anthropic ตัว​ทางการ​เป็น​หลัก)

wiring ขั้น​ต่ำ: สร้าง client จาก package Anthropic แล้ว​สวม​เข้า​หน้า IChatClient

using Anthropic;
using Microsoft.Extensions.AI;
AnthropicClient client = new(); // อ่าน ANTHROPIC_API_KEY จาก env เอง
IChatClient chatClient = client
.AsIChatClient("claude-opus-4-8") // model id: ตัวอย่าง ณ 2026-07 — ไม่ต่อ date suffix
.AsBuilder()
.UseFunctionInvocation() // เปิด middleware ที่วน loop ของ tool ให้เอง
.Build();

สังเกต​ว่า header สาม​ตัว​จาก​บท 2 (x-api-key, anthropic-version: 2023-06-01, content-type) หาย​ไป​จาก​สายตา — ไม่ใช่​หาย​ไป​จริง แต่ AnthropicClient ใส่​ให้​เอง​ใต้​ฝากระโปรง คุณ​จึง​ไม่​ต้อง​จำ และ​ไม่มี​ทาง​ลืม

นี่​คือ​จุด​ที่ MEAI จ่าย​ค่าตัว​คืน ในบท 4 คุณ​เขียน branch if stop_reason == "tool_use" เอง รัน tool เอง ต่อ tool_result เอง แล้ว​วน​เอง — MEAI ยุบ​ทั้งหมด​นั้น​เหลือ await เดียว

กลไก​มี​สาม​ชิ้น​ที่​ต้อง​รู้จัก:

  • AIFunctionFactory.Create(delegate, name, description) — ห่อ method C# ธรรมดา​ให้​กลาย​เป็น AIFunction (เทียบ​บท 4 คือ​ตัว​สร้าง element ใน array tools ให้ พร้อม​สังเคราะห์ schema จาก signature ของ delegate)
  • ChatOptions.Tools — ที่​วาง list AIFunction เหล่า​นั้น
  • .UseFunctionInvocation() — เปิด middleware ชื่อ FunctionInvokingChatClient ที่​คอย: เห็น model ขอ​เรียก function → รัน delegate นั้น​ให้ → ยัด​ผลกลับ​เป็น message → เรียก model ต่อ — วนจน model ได้​คำ​ตอบ​สุดท้าย ทั้งหมด​ใน​การ await ครั้ง​เดียว​ของ​คุณ
var chatOptions = new ChatOptions
{
Tools =
[
AIFunctionFactory.Create(
(string orderId) => /* ไปดึงออเดอร์จริง */ "{\"status\":\"OutForDelivery\"}",
"getOrder",
"Retrieve a food-delivery order by its ID.")
]
// NOTE: อย่าตั้ง chatOptions.Temperature กับ model Claude รุ่นล่าสุด — ได้ HTTP 400
};
List<ChatMessage> history =
[
new(ChatRole.System, "You are a customer-support agent for a food-delivery service.")
];
history.Add(new(ChatRole.User, "What's the status of order ORD-1042?"));
ChatResponse response = await chatClient.GetResponseAsync(history, chatOptions);
Console.WriteLine(response.Text); // อ่านคำตอบตรง ๆ — MEAI รวม content[] ให้แล้ว
history.AddMessages(response); // เก็บ turn ทั้งหมด (รวมรอบ tool) กลับเข้าประวัติ

รอบ tool_usetool_result ทั้ง​ชุด​จาก​บท 4 เกิด​ขึ้น ข้าง​ใน GetResponseAsync บรรทัด​เดียว​นี้ ถ้า model ตัดสิน​ใจ​เรียก getOrder middleware รัน delegate ให้​แล้ว​วน​ต่อ​โดยที่​คุณ​ไม่​ต้อง​เขียน branch เอง เมื่อ​จบ response.Text คือ​คำ​ตอบ​สุดท้าย — ไม่มี content[] ให้​ไล่ index อีก

🔁 map กลับ​ไป​บท 4

ทุก​อย่าง​ที่ middleware ทำมี counterpart ตรงๆ ใน API ดิบ: AIFunction = element ใน array tools, การ​ที่ model ขอ​เรียก function = block tool_use, message ผลลัพธ์​ที่ middleware ยัด​กลับ = block tool_result MEAI ไม่​ได้​เปลี่ยน protocol — มัน​แค่​เขียน loop ที่​คุณ​ต่อ​มือ​ใน​บท 4 ให้​แทน นี่​คือ loopAgent Loopวงจร observe → decide → act → repeat ที่​ทำซ้ำ​จนถึง​เงื่อนไข​หยุด — model สังเกต​ผล​จาก​สภาพ​แวดล้อม (ผล​ของ tool) แล้ว​ตัดสิน​ใจ​ก้าว​ถัด​ไป วน​ไป​จนกว่า​จะ​ได้​คำ​ตอบ​สุดท้าย (ไม่มี `tool_use` แล้ว), ชน​เพดาน​จำนวน​รอบ, ต้อง​ขอ​อนุมัติ​จาก​คน หรือ​เกิน​งบ token/ต้นทุน — ต้อง​รักษา​ให้​มี​เงื่อนไข​หยุด​เสมอ​เพื่อ​คุม​ไม่​ให้​วน​ไม่​จบ ใน Microsoft.Extensions.AI ตัว `.UseFunctionInvocation()` ทำ​วง​นี้​ให้​อัตโนมัติArchitecture ตัว​เดียว​กับ​ที่​บท 6 จะ​กาง​ออก​ให้​เห็น​เต็มๆ

flowchart TB
  subgraph RAW["version ดิบ — ต่อท่อเอง (บท 4)"]
    R1["ส่ง messages บวก tools"] --> R2{"อ่าน stop_reason"}
    R2 -->|tool_use| R3["แตก branch เอง<br/>รัน getOrder ใน C#"]
    R3 --> R4["ต่อ tool_result<br/>ส่งทั้ง list ใหม่ทั้งก้อน"]
    R4 --> R2
    R2 -->|end_turn| R5["ไล่ content[] อ่านคำตอบ"]
  end
  subgraph MEAI["IChatClient บวก function-invocation middleware"]
    M1["GetResponseAsync ครั้งเดียว"] --> MW["middleware วนเรียก tool ให้เอง"]
    MW --> M2["response.Text"]
  end
  classDef mid fill:#ea580c,stroke:#7c2d12,color:#f8fafc;
  class MW mid;

คำ​บรรยาย​ภาพ: บน — version ดิบ​จาก​บท 4 คุณ​ต่อ loop ด้วย​มือ: อ่าน stop_reason เอง แตก branch เอง รัน getOrder เอง ต่อ tool_result แล้ว​ส่ง​ทั้ง list กลับ​เข้าไป​ใหม่ วนจน end_turn แล้ว​ไล่ content[] อ่าน​คำ​ตอบ ล่าง — ฝั่ง IChatClient การ await GetResponseAsync ครั้ง​เดียว​พอ จุด​สี​ส้ม​คือ function-invocation middleware ที่​วน​เรียก tool ให้​แทนคุณ​ทั้ง​วง แล้ว​คืน response.Text เป็น​คำ​ตอบ​สุดท้าย protocol เบื้องหลัง​เหมือนกันเป๊ะ ต่าง​แค่​ใคร​เป็น​คน​เขียน loop

เพราะ IChatClient เป็น interface ตาม​สำนวน .NET มัน​จึง​เข้า container ของ dependency injection ได้ตรงๆ ลำดับ​ที่ chain มี​ความหมาย — middleware ประกอบ​กัน​จาก​นอก​เข้า​ใน:

builder.Services.AddChatClient(innerClient)
.UseFunctionInvocation()
.UseDistributedCache()
.UseOpenTelemetry();
// ที่อื่นใน app: ขอ IChatClient จาก DI ได้เลย
// var chat = host.Services.GetRequiredService<IChatClient>();

พอ inject IChatClient เข้า service ของ domain Order ได้ ก็​หมายความ​ว่า test สลับ​ตัว fake เข้า​แทน Claude ได้​ทันที และ​ย้าย cross-cutting concern อย่าง caching กับ telemetry ไป​เป็น middleware แทนที่​จะ​ปน​ใน logic ธุรกิจ — นี่​คือ​เหตุผล​จริง​ที่​ยอม​แลก abstraction เพิ่ม​มา​หนึ่ง​ชั้น

type ที่​ควร​จำ​หน้าตา​ไว้: ChatRole.{System, User, Assistant, Tool}, ChatResponse.{Text, Messages, ConversationId}, และ ChatOptions.{Tools, ModelId, ConversationId, Temperature, AdditionalProperties} — ข้อ​ควร​ระวัง​เดิม​ย้ำ​อีก​ครั้ง: ChatOptions มี field Temperature ให้​ตั้ง​ก็​จริง แต่ อย่า​ตั้ง​กับ model Claude รุ่น​ล่าสุด เพราะ​ได้ HTTP 400 เหมือน​ตอน​ส่ง​ใน JSON ดิบ กำกับ​พฤติกรรม​ผ่าน prompt แทน

การ​เรียก​ไม่​ได้​เปลี่ยน​เลย — ยัง​เป็น Messages API ตัว​เดิม header เดิม protocol tool เดิม — เปลี่ยน​แค่ ผิว​สัมผัส ที่​คุณ​เขียน: ChatMessage/ChatRole แทน JSON ดิบ, GetResponseAsync แทน HttpClient ที่​ต่อ​มือ, และ .UseFunctionInvocation() ที่​เขียน loop ของ tool ให้​แทนคุณ สิ่ง​ที่​ยัง​ต้อง​ระวัง​เอง​มี​สอง​ข้อ: connector ของ Claude ยัง​เป็น beta (pin version) และ​ห้าม​ตั้ง Temperature บท​หน้า​เรา​จะ​กาง loop ที่ middleware ซ่อน​ไว้​ออก​มา​ดู​เต็มๆ — ทั้ง​เงื่อนไข​หยุด, max-iterations guard และ​จุด​ที่​ต้อง​หยุด​รอ​มนุษย์​อนุมัติ​ก่อน​ลงมือ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​สังเคราะห์​จาก​ห้า​แหล่ง​หลัก อ่าน​ต่อ​ได้ที่​ต้นทาง​โดยตรง:

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

เทียบกับการต่อ tool use ด้วยมือในบท 4 อะไรคือสิ่งที่ .UseFunctionInvocation() ทำให้?