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

ความ​ทนทาน: เมื่อ tool พัง loop ต้อง​ไม่​ล้ม

หกบท​ที่​ผ่าน​มา​เรา​สร้าง loop ที่ ฉลาด​ขึ้น — routing, parallelization, orchestrator-workers, reflection บท​นี้​ถอย​กลับ​มา​ถาม​คำถาม​ที่​ทุก loop ต้อง​รอด: เมื่อ tool พัง จะ​เกิด​อะไร​ขึ้น? network timeout, rate limit, order ที่​ไม่มี​อยู่​จริง, refund ที่​ยิง​ไป​แล้ว​แต่ response หาย — เหตุ​พวก​นี้​ไม่ใช่ ข้อ​ยกเว้น ที่​จะ​เกิด​นานๆ ครั้ง มัน​คือ สภาพ​ปกติ ของ​ระบบ​ที่​คุย​กับ​โลก​ภายนอก และ​งาน​ของ harness คือ​รักษา loop ให้​หมุน​ต่อ​ได้​ทั้ง​ที่ tool ข้าง​ใน​มัน​ล้ม

กลับ​ไป​ดู catch ใน hand-rolled loop ของ บท1 🔁 — บรรทัด​ที่​เขียน​ว่า catch (Exception ex) { output = $"ERROR: {ex.Message}"; } เรา​จงใจ​วาง​มัน​ไว้​เป็น ตะเข็บ บท​นี้​คือ​บท​ที่​เปิด​ตะเข็บ​นั้น​ออก​มา​ทำให้​ครบ: จาก​บรรทัด​เดียว​ที่​กัน crash ให้​กลาย​เป็น​สี่​ชั้น​ของ​ความ​ทนทาน — ให้ model เห็น error, retry อย่าง​มี​จังหวะ, ตั้ง timeout, และ​รักษาการกระทำ​ที่​มี​ผล​ข้าง​เคียง​ไม่​ให้​ทำซ้ำ

📦 code ตัวอย่าง

บท​นี้​ยัง​ทำงาน​บน agent Order ตัว​เดิม (repo kaen-food-ordering — กำลัง​จัด​ทำ) tool สาม​ตัว​เดิม getOrder / getDeliveryStatus / issueRefund ไม่​เพิ่ม​ใหม่ แต่​เรา​ห่อ ชั้น​ความ​ทนทาน คร่อม​การ​เรียก​ทั้ง​การ​เรียก model และ​การ dispatch tool ทุก snippet ที่​แตะ Messages API / IChatClient ยัง​ยึด invariant เดิม​ของ #15 ทุก​ข้อ (header x-api-key, anthropic-version: 2023-06-01, ห้าม​ส่ง temperature, model id เปล่า claude-opus-4-8 ลง​วัน​ที่ 2026-07, อ่าน Contents[] ไม่ใช่ .text, Claude อยู่​หลัง NuGet Anthropic beta ผ่าน AsIChatClient)

สัญชาตญาณ​ของ​โปรแกรมเมอร์​เมื่อ tool โยน exception คือ​ปล่อย​ให้​มัน propagate ขึ้น​ไป​หยุด​โปรแกรม แต่​ใน​บริบท​ของ agent นี่​คือ​ความ​ผิดพลาด — เพราะ model คือ​ส่วน​ที่​กู้​สถานการณ์​ได้ ถ้า​คุณ​ให้​มัน​เห็น error การ​กู้​คืน​จาก tool errorTool-error Recoverymodel ต้อง 'เห็น' error — คืน error result (raw: `tool_result` ที่ `is_error: true`; ใต้ IChatClient: string จาก catch ป้อน​กลับ​เป็น FunctionResultContent) ไม่ใช่​ปล่อย​ให้ crash ป้อน error ที่ 'สอน​ได้' (บอกว่า​เกิด​อะไร​และ​ควร​ลอง​อะไร​ต่อ ไม่ใช่ 'failed' เปล่า ๆ) กลับ​เข้า loop ให้ model retry/re-route เอง — Claude retry tool call ที่​ผิด 2–3 ครั้ง​เมื่อ​ได้ error กลับ​ไป (Anthropic)Process เริ่ม​จาก​กฎ​เดียว: จับ error แล้ว ป้อน​กลับ​เข้า loop ใน​ฐานะ​ผล​ของ tool ไม่ใช่​โยน​ทิ้ง​ให้ loop ตาย

ใน raw Messages API เรื่อง​นี้​คือ​การ​คืน tool_result ที่​มี is_error: true — Anthropic ระ​บุตรงๆ ว่า “If the tool itself throws an error during execution (for example, a network error when fetching weather data), you can return the error message in the content along with "is_error": true… Claude will then incorporate this error into its response to the user.” ใต้ IChatClient ตัว​เทียบ​คือ string ที่​จับ​ได้​จาก catch แล้ว​ส่ง​กลับ​เป็น FunctionResultContent — ก็​คือ catch ในบท1

แต่ ข้อความ ที่​ป้อน​กลับ​ต้อง​สอน model ว่า​จะ​ทำ​อะไร​ต่อ ไม่ใช่​แค่ code เปล่า Anthropic เขียน​ไว้​ว่า “Write instructive error messages. Instead of generic errors like "failed", include what went wrong and what Claude should try next, e.g., "Rate limit exceeded. Retry after 60 seconds." This gives Claude the context it needs to recover or adapt without guessing.” — และ​ใน​คู่มือ​เขียน tool เขา​ย้ำ​หลัก​เดียวกัน: “if a tool call raises an error (for example, during input validation), you can prompt-engineer your error responses to clearly communicate specific and actionable improvements, rather than opaque error codes or tracebacks.” เพราะ​เมื่อ error บอก​ทาง​แก้ Claude แก้​เอง​ได้ — “If a tool request is invalid or missing parameters, Claude will retry 2-3 times with corrections before apologizing to the user.”

List<AIContent> results = [];
foreach (var call in calls) {
object? output;
try {
output = await Dispatch(call.Name, call.Arguments);
}
catch (OrderNotFoundException) {
// ข้อความสอน: บอกว่าเกิดอะไรและให้ลองอะไรต่อ ไม่ใช่แค่ "failed"
output = "ERROR: ไม่พบ order ตาม id นี้ ตรวจรูปแบบ id (เช่น A-1002) แล้วลองใหม่ หรือถามผู้ใช้ให้ยืนยัน id";
}
catch (Exception ex) {
output = $"ERROR: {ex.Message} — ลองอีกครั้งหรือเปลี่ยนวิธี"; // ป้อนกลับ ไม่ throw
}
results.Add(new FunctionResultContent(call.CallId, output));
}
messages.Add(new ChatMessage(ChatRole.Tool, results)); // ผลของ tool อยู่ใน ChatRole.Tool message เสมอ
สอง​กฎ​ที่​ทำ loop พัง​เงียบๆ ถ้า​พลาด

1) ผล​ของ tool ต้อง​ตาม​หลัง tool call ทันที — Anthropic ระบุ​กติกา format ที่​ถ้า​ละเมิด​จะ error เงียบๆ: “Tool result blocks must immediately follow their corresponding tool use blocks.” ถ้า​คุณ​แทรก​ข้อความ​อื่น​คั่น​ระหว่าง tool_use กับ tool_result request จะ​ถูก​ปฏิเสธ hand-rolled loop ของ​บท1 รักษา​กฎ​นี้​ให้​อยู่​แล้ว​โดย​ผนวก assistant turn ก่อน​แล้ว​จึง​ตาม​ด้วย ChatRole.Tool message

2) ผล​ของ tool คือ​ข้อมูล ที่​ไม่​น่า​เชื่อถือ“Tool results often carry content from sources outside your control… Treat that content as untrusted: an attacker who can influence it may embed instructions that try to redirect Claude (indirect prompt injection). Keep untrusted content inside tool_result blocks rather than system prompts.” อย่า​เลื่อน record ออเดอร์​ที่ tool คืน​มา​ไป​ต่อ​ท้าย system prompt — เก็บ​มัน​ไว้​ใน block ผล​ของ tool เท่านั้น

ไม่ใช่​ทุก error ควร retry การ retryRetryการ​ลอง​ใหม่​เมื่อ call ล้มเหลว ด้วย exponential backoff + jitter และ​เพดาน​จำนวน​ครั้ง — เฉพาะ fault ชั่วคราว (transient) เท่านั้น; ยกเลิก​ทันที​ถ้า​เป็น​ถาวร​หรือ error จาก business logic (ไม่​ควร retry) ⚠️ 2 layer ทับ​กัน: SDK `Anthropic` retry ระดับ transport ให้​อยู่​แล้ว (`MaxRetries`) ส่วน Polly คือ​ระดับ agent-loop (retry ทั้ง​เทิร์น, per-turn timeout) — อย่า double-retry transportProcess ปลอดภัย​เฉพาะ​กับ fault ชั่วคราว (transient) — timeout, rate limit, 5xx — ที่​มี​โอกาส​หาย​เอง​ถ้า​ลอง​ใหม่ ส่วน fault ถาวร​อย่าง business-logic error (order นี้​คืน​เงิน​ไม่​ได้​เพราะ​เลย​กำหนด) การ retry มี​แต่​จะ​เปลือง​เปล่า Microsoft Retry pattern วาง​เส้น​แบ่ง​นี้​ชัด — กลยุทธ์ “Cancel. If the fault indicates that the failure isn’t transient or is unlikely to be successful if repeated, the application should cancel the operation and report an exception.” และ​ย้ำ​ว่า retry ไม่​ได้​มี​ไว้ “handling failures that aren’t due to transient faults, such as internal exceptions caused by errors in the business logic.”

เมื่อ retry ต้อง ถอย​จังหวะ (back off) เพิ่ม​ขึ้น​ต่อ​เนื่อง มี​เพดาน และ​มี jitter“this process can be repeated with increasing delays between retry attempts, until some maximum number of requests have been attempted. The delay can be increased incrementally or exponentially, depending on the type of failure.” เหตุ​ที่​ต้อง jitter คือ​กัน retry storm: “An aggressive retry policy with minimal delay between attempts, and a large number of retries, could further degrade a busy service that’s running close to or at capacity.” ถ้า client ทุก​ตัว retry พร้อม​กัน​เป๊ะๆ มัน​จะ​ซัด​บริการ​ที่​กำลัง​จะ​ฟื้น​ให้​ล้ม​ซ้ำ

⚠️ สอง​ชั้น retry ที่​ทับ​กัน — อย่า retry ซ้อน

นี่​คือ​กับดัก​ที่​คน​พลาด​กัน​บ่อย​ที่สุด​ใน​บท​นี้ — NuGet Anthropic ตัว​ทางการ retry ให้​อยู่​แล้ว ที่​ชั้น transport — โดย default มัน retry ด้วย exponential backoff บน 408/409/429/5xx และ connection error (ปรับ​ผ่าน property MaxRetries) ถ้า​คุณ​เอา Polly ไป​ครอบ​แล้ว​ตั้ง retry บน error ชุด​เดียวกัน​อีก คุณ​จะ​ได้ retry คูณ​กัน — 3 × 2 = 6 ครั้ง​ต่อ​การ​เรียก​หนึ่ง​ครั้ง ยิง​ใส่​บริการ​ที่​ล่ม​หนัก​ขึ้น

เส้น​แบ่ง​ที่​ถูก: ปล่อย​ให้ SDK รักษา​ชั้น transport (retry ตัว HTTP request ราย​ตัว) ส่วน Polly รักษา​ชั้น agent-loop — retry ทั้ง​เทิร์นที่​ล้ม หรือ​คุม timeout ต่อ​เทิร์น ไม่ใช่ retry HTTP ซ้ำ​สิ่ง​ที่ SDK ทำ​แล้ว

Polly v8 (ResiliencePipelineBuilder ผ่าน Microsoft.Extensions.Http.Resilience — GA ไม่ใช่ beta) ประกอบ retry + timeout ที่​ชั้น agent-loop แบบ​นี้:

using Polly;
using Polly.Retry;
using Polly.Timeout;
ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions {
ShouldHandle = new PredicateBuilder().Handle<AnthropicRateLimitException>() // 429
.Handle<Anthropic5xxException>() // 5xx
.Handle<TimeoutRejectedException>(), // จาก AddTimeout ด้านล่าง
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // กระจายจังหวะ retry — กัน retry storm
})
.AddTimeout(TimeSpan.FromSeconds(60)) // timeout ต่อความพยายาม; ลำดับสำคัญ (retry ครอบ timeout)
.Build();
ChatResponse resp = await pipeline.ExecuteAsync(
async token => await agent.GetResponseAsync(messages, options, token), ct);
ตรวจ​ชื่อ exception ก่อน ship

ชื่อ type ใน block PredicateBuilder ข้าง​บน (AnthropicRateLimitException, Anthropic5xxException, และ base AnthropicApiException) เป็น รูปทรง​ที่​ต้อง​ยืนยัน​กับ​ตาราง error ของ Anthropic 12.8.0 ที่​ติดตั้ง​จริง ก่อน​ใช้งาน — SDK ตัว​นี้​เป็น beta ชื่อ exception เป็น​พื้น​ผิว​ที่​ขยับ​ได้ อย่า copy ชื่อ​ไป​วาง​แล้ว​เชื่อ​ว่า​ถูก ให้​เปิด error table ของ version ที่ pin ไว้​เช็กก่อน และ​อย่า​ลืม​ว่า MaxRetries ของ SDK ยัง​ทำงาน​อยู่​ข้าง​ใต้ — ถ้า​จะ​ให้ Polly คุม​ทั้งหมด ให้​ลด MaxRetries ของ SDK ลง (เช่น client.WithOptions(o => o with { MaxRetries = 0 })) เพื่อ​ไม่​ให้​สอง​ชั้น​ทับ​กัน

หลัก​สุดท้าย​ของ retry: อย่า​ซ้อน retry หลาย​ชั้น​โดย​ไม่รู้ตัว และ​บาง​เส้นทาง​ควร ล้ม​เร็ว ดี​กว่า — “Implement retry logic only where the full context of a failing operation is understood… It might be better to configure the lower-level task to fail fast.” และ “For some noncritical operations, it’s better to fail fast rather than retry several times and affect the throughput of the application.” บน​เส้นทาง​ที่​ผู้​ใช้​กำลัง​รอ​คำ​ตอบสดๆ การ retry หลาย​รอบ​ทำให้ latency บวม​จน​แย่​กว่า​การ​ยอม​บอกว่า “ตอน​นี้​ทำ​ไม่​ได้”

timeout เป็น​กลยุทธ์ resilience ชั้น​หนึ่ง​เต็ม​ตัว ไม่ใช่​ของ​แถม บน​เส้นทาง interactive การ​ปล่อย​ให้​เทิร์น​หนึ่ง​ค้าง​สอง​นาที​แย่​กว่า​การ​ตัด​จบ​แล้ว​บอก model ให้​ลอง​ใหม่ ใน .NET เครื่องมือ​คือ CancellationToken — และ Microsoft แยก​สอง​มิติ​ของ timeout ไว้: timeout ต่อ​ความ​พยายาม (attempt) กับ timeout รวม​ทั้ง​งาน (total) — คุณ​อยาก​ได้​ทั้ง​สอง: เพดาน​ต่อ​ความ​พยายาม (ใน Polly คือ .AddTimeout) และ​เพดาน​เวลา​รวม​ทั้ง​เทิร์น

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromMinutes(2)); // เพดานเวลารวมทั้งเทิร์น
var resp = await agent.GetResponseAsync(messages, options, cts.Token);

จุด​สำคัญ: เมื่อ timeout สะดุด อย่า​ปล่อย​ให้ exception พุ่ง​ขึ้น​ไป​ฆ่า loop — จับ​มัน​แล้ว แปลง​เป็น​ข้อความ​สอน ป้อน​กลับ เหมือน​ชั้น​ที่ 1 นั่น​คือ ให้ timeout กลาย​เป็น observation ที่ model เห็น​และ​ตัดสิน​ใจ​ต่อ​ได้

try {
output = await Dispatch(call.Name, call.Arguments, cts.Token);
}
catch (OperationCanceledException) {
output = "ERROR: tool ทำงานเกินเวลาที่กำหนด ลองอีกครั้งหนึ่ง หรือเปลี่ยนไปใช้ tool อื่นที่เร็วกว่า";
}

นี่​คือ​ชั้น​ที่​อันตราย​ที่สุด และ​เป็น​เหตุผล​ที่​เส้น​แบ่ง read/write จาก​บท1 สำคัญ getOrder / getDeliveryStatus เป็น read-only — retry ซ้ำ​กี่​ครั้ง​ก็​ไม่​เจ็บ แต่ issueRefund เป็น write action ที่​มี​ผล​ข้าง​เคียง​กับ​เงิน​จริง และ retry ทำให้​เกิด​ฝัน​ร้าย​คลาสสิก Microsoft อธิบาย​ไว้​ตรง​เป๊ะ:

“Consider whether the operation is idempotent. If so, it’s inherently safe to retry. Otherwise, retries could cause the operation to be executed more than once, with unintended side effects. For example, a service might receive the request, process the request successfully, but fail to send a response. At that point, the retry logic might re-send the request, assuming that the first request wasn’t received.”

แปล​เป็น​สถานการณ์​ของ​เรา: issueRefund คืน​เงิน​สำเร็จ → แต่ response หาย​ระหว่าง​ทาง (timeout พอดี) → retry ยิง issueRefund ซ้ำ → ลูกค้า​ได้​เงิน​คืน​สอง​รอบ idempotencyIdempotencyคุณสมบัติ​ที่​ทำให้ retry ปลอดภัย — tool ที่​มี side effect (issueRefund) ต้อง​ไม่​ทำซ้ำ​เมื่อ​ถูก retry มิ​ฉะนั้น refund สำเร็จ แต่ response หาย แล้ว retry ยิง​ซ้ำ → refund สอง​ครั้ง วิธี​แก้: สร้าง idempotency key 'ก่อน' เข้า retry loop server เช็ค key แล้ว​คืน​ผล​ที่​เก็บ​ไว้​แทน​การ​ประมวล​ซ้ำ ⚠️ (แปล​ความ) key ไม่ใช่​หลัก​ประกัน​เต็ม — side effect (event, email, external call) รั่ว​ได้ รูป​ที่​ทนทาน​คือ​เขียน intent + ledger ใน transaction เดียว​แล้ว​ส่ง​ผ่าน outbox (โยง #10 ledger + #8 outbox)Architecture คือ property ที่​ทำให้​เรียก​ซ้ำ​ได้​ผล​เท่า​เดิม และ​มัน​คือ​เงื่อนไข ก่อน ที่ retry บน write action จะ​ปลอดภัย

กลไก idempotency key (เรียบเรียง​จาก S14 — Adyen/Stripe/PayPal ใช้​รูปแบบ​นี้): client สร้าง key เฉพาะ (เช่น UUID) ก่อน เข้า loop ของ retry แล้ว​แนบ​ไป​กับ request ผ่าน header อย่าง Idempotency-Key ฝั่ง server เห็น key ซ้ำ​ก็​คืน ผล​ที่​เก็บ​ไว้​เดิม แทน​การ​ประมวล​ผล​ใหม่ — retry จึง​ไม่มี​ทาง​คืน​เงิน​สอง​รอบ กุญแจ​สำคัญ​คือ key ต้อง​เกิด ก่อน retry loop ไม่ใช่​ต่อ​รอบ ไม่​งั้น​แต่ละ retry จะมี key ใหม่​และ​หมด​ความหมาย

// tool คืนเงินรับ idempotency key จากผู้เรียก; ledger บังคับ "1 key คืนเงินได้ครั้งเดียว"
static RefundResult IssueRefund(string orderId, decimal amount, string idempotencyKey) =>
RefundLedger.IssueOnce(orderId, amount, idempotencyKey); // key ซ้ำ → คืนผลเดิมที่เก็บไว้ ไม่คืนเงินใหม่
idempotency key ไม่ใช่​การ​การันตี (เรียบเรียง​จาก S15)

กับดัก​ที่​ต้อง​พูด​ให้​ตรง: unique constraint บน key รักษา​แค่ การ​เขียน DB แถว​เดียว แต่​ผล​ข้าง​เคียง​ที่ ไม่​ได้ อยู่​ใน transaction เดียวกัน — event ที่ publish, อีเมล​แจ้ง​ลูกค้า, การ​เรียก payment processor ภายนอก — มัก​หลุด​ออก​นอก​ร่ม​การ​ป้องกัน​นั้น เรียก issueRefund ซ้ำ​แล้ว​เจอ key เดิม​อาจ​ไม่​เขียน DB ซ้ำ แต่​ถ้า code publish event หลัง commit มัน​ก็​ยิง event ซ้ำ​ได้​อยู่ดี

รูปแบบ​ที่​ทนทาน​จริง​คือ เขียน intent + ledger ใน1 transaction แล้ว publish ผ่าน outbox ที่ durable — บันทึก​ความ​ตั้งใจ​จะ​คืน​เงิน​ลง ledger พร้อม​กับ mark ว่า​จัดการ key นี้​แล้ว​ใน​ธุรกรรม​เดียว จาก​นั้น​ให้​ตัว outbox เป็น​คน​ส่ง event ออก​ไป​แบบ at-least-once ที่​ผู้รับ dedupe เอง pattern นี้​ตรง​กับ ledger ที่​คุณ​สร้าง​ใน​คอร์ส #10 และ outbox แบบ Wolverine ใน​คอร์ส #8 — idempotency ของ agent tool ที่​แตะ​เงิน​จริง​ต้อง​พิง​โครงสร้าง​พวก​นั้น ไม่ใช่​แค่ if-check ใน method

สอง​มาตรการ​ที่​กัน error ก่อน มัน​เกิด อยู่​คู่​กับ​สี่​ชั้น​ข้าง​บน

guardrailGuardrailด่าน​ตรวจ/ขีด​จำกัด​ที่​วาง​ไว้​ก่อน​หรือ​รอบ​การ execute เพื่อ​รักษา​ให้​ทำงาน​อย่าง​ปลอดภัย — validate ก่อน​รัน (`strict: true` บน tool schema กัน parameter ขาด/ผิด​ชนิด), ปฏิบัติ​ต่อ​ผล​ของ tool เป็น 'untrusted' (indirect prompt injection — เก็บ​ไว้​ใน tool_result ไม่ใช่ system prompt), และ cap iteration/error BEA เตือน​ว่า agent มี 'higher costs, and the potential for compounding errors' จึง​ต้อง test ใน sandbox พร้อม guardrail ที่​เหมาะสมProcess ที่ input — validate ก่อน​รัน (เรียบเรียง​จาก S17): ตั้ง strict: true บน​นิยาม tool เพื่อ​การันตี​ว่า input เข้า​คู่ schema — กัน parameter ขาด​และ​ชนิด​ผิด — และ​ใส่ input_examples เพื่อ​ลด tool call ที่ malformed นี่​คือ​การ​ลงทุน​ใน ACI ต่อ​จาก​หลัก “ลด input error” ของ คอร์ส #15 🔁 error ที่​ดี​ที่สุด​คือ error ที่​ไม่​เกิด และ Anthropic ก็​เตือน​ไว้​ว่า​ธรรมชาติ autonomous ของ agent “means higher costs, and the potential for compounding errors. We recommend extensive testing in sandboxed environments, along with the appropriate guardrails.”

อ่าน stop_reason ก่อน​ประมวล​ผล — ก่อน​จะ​ไล่ Contents[] ควร branch บน​เหตุ​ที่ model หยุด Anthropic แจกแจง stop_reason ไว้​หลาย​ค่าที่​ไม่ใช่ end_turn ปกติ: max_tokens (คำ​ตอบ​ถูก​ตัด​กลางคัน — อาจ​ตัด tool call ขาด), model_context_window_exceeded, refusal, pause_turn การ​เจอ max_tokens แล้ว​เดิน​หน้า parse tool call ต่อ​ทั้ง​ที่​มัน​ขาด​ครึ่ง​คือ bug ที่​หา​ยาก — ตรวจ stop_reason แล้ว​จัดการ​เคส​ตัด​จบ​ก่อน​เสมอ

flowchart TD
  subgraph FRAGILE["❌ version ดิบ — call เปราะ"]
    F1["dispatch tool"] --> F2{"tool โยน exception"}
    F2 -->|"ใช่"| F3["exception พุ่งขึ้น<br/>loop ตาย"]
    F2 -->|"timeout"| F4["ค้างยาว<br/>ผู้ใช้รอเก้อ"]
    F5["issueRefund retry"] --> F6["คืนเงินซ้ำ<br/>side effect"]
  end
  subgraph ROBUST["✅ call ที่ทน — 4 ชั้น"]
    R1["dispatch tool"] --> R2{"error ไหม"}
    R2 -->|"error"| R3["จับ แล้วป้อน is_error<br/>+ ข้อความสอน กลับเข้า loop"]
    R2 -->|"transient"| R4["retry: backoff+jitter<br/>เฉพาะ fault ชั่วคราว"]
    R2 -->|"ช้าเกิน"| R5["timeout ด้วย CancellationToken<br/>แปลงเป็นข้อความสอน"]
    R6["issueRefund retry"] --> R7["idempotency key<br/>คืนผลเดิม ไม่คืนเงินซ้ำ"]
    R3 --> R8["model เห็น error<br/>แก้เอง 2-3 ครั้ง"]
    R4 --> R8
    R5 --> R8
  end
  classDef bad fill:#7f1d1d,stroke:#450a0a,color:#fca5a5;
  classDef good fill:#166534,stroke:#052e16,color:#bbf7d0;
  class F3,F4,F6 bad;
  class R3,R4,R5,R7,R8 good;

คำ​บรรยาย​ภาพ: ฝั่ง​ซ้าย​คือ call เปราะ — exception พุ่ง​ขึ้น​ฆ่า loop, timeout ปล่อย​ให้​ค้าง, retry บน issueRefund คืน​เงิน​ซ้ำ ฝั่ง​ขวา​คือ call เดียวกัน​ที่​รักษา​ไว้​สี่​ชั้น — จับ error แล้ว​ป้อน is_error + ข้อความ​สอน​กลับ​เข้า loop, retry เฉพาะ fault ชั่วคราว​ด้วย backoff+jitter, ตั้ง timeout ด้วย CancellationToken, และ​รักษา write action ด้วย idempotency key ปลายทาง​ทั้ง​สาม​เส้น​ฝั่ง​ขวา​ไป​รวม​ที่​เดียว: model เห็น สิ่ง​ที่​พลาด​และ​แก้​เอง​ได้

ทาง​ลัด​ฝั่ง middleware: เพดาน error ใน​ตัว

ถ้า​คุณ​เดิน​บน middleware loop (.UseFunctionInvocation()) แทนที่​จะ hand-roll เอง ตัว FunctionInvokingChatClient มี knob สำหรับ​เพดาน error ให้​ใน​ตัว: MaximumConsecutiveErrorsPerRequest หยุด loop หลัง​ล้ม​ติด​กัน N รอบ และ IncludeDetailedErrors ส่ง​รายละเอียด exception กลับ​เข้า model ให้​มัน​แก้​เอง (แทนที่​ข้อความ generic) ใช้​สอง​ตัว​นี้​แทน​การ hand-roll เมื่อ​อยู่​บน middleware — แต่ retry แบบ backoff, timeout ต่อ​เทิร์น และ idempotency ยัง​ต้อง​ประกอบ​เอง​อยู่ดี knob พวก​นี้​เป็น​พื้น​ผิว​ที่​ขยับ​เร็ว ให้​ยืนยัน​ชื่อ​กับ version Microsoft.Extensions.AI ที่​ติดตั้ง​จริง

tool พัง​คือ​สภาพ​ปกติ ไม่ใช่​ข้อ​ยกเว้น และ​ความ​ทนทาน​คือ​สี่​ชั้น​ที่​ห่อ catch บรรทัด​เดียว​จาก​บท1: (1) ให้ model เห็น error ผ่าน is_error + ข้อความ​ที่ สอน ให้​มัน​แก้​เอง (2) retry เฉพาะ fault ชั่วคราว​ด้วย backoff+jitter โดย​ไม่ retry ซ้อน​ชั้น​ที่ SDK ทำ​แล้ว (3) ตั้ง timeout ด้วย CancellationToken แล้ว​แปลง​เป็น​ข้อความ​สอน (4) รักษา write action อย่าง issueRefund ด้วย idempotency key ที่​พิง ledger + outbox จริง เสริม​ด้วย guardrail ที่ input และ​การ​อ่าน stop_reason ก่อน parse

สังเกต​ว่า​สี่​ชั้น​นี้​คือ​การ​เติม clause “error ที่​กู้​ไม่​ได้” เข้าไป​ใน​นโยบาย​หยุด​แบบ​ประกอบ​ที่​บท1 วาง​ไว้ — เมื่อ retry หมด​ทุก​ชั้น​แล้ว​ยัง​ล้ม นั่น​คือ​สัญญาณ​ให้ loop หยุด​อย่าง​มี​เหตุผล​และ escalate ไม่ใช่​วน​ต่อ​ไม่​จบ บท​หน้า — บท​ที่ 8 🔁 — เรา​ประกอบ​ทั้ง5 clause (task-complete, step budget, cost budget, error ที่​กู้​ไม่​ได้, human-checkpoint) เข้า​เป็น LoopController ตัว​เดียว แล้ว​ปิด​คอร์ส​ด้วย​ความ​จริง​เรื่อง​ต้นทุน​และ error ที่​ทบ​ต้น


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

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

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

ข้อ 1 / 3

agent เรียก tool แล้ว tool โยน exception ควรทำอย่างไรจึงจะกู้คืนได้ตามหลัก tool-error recovery?