รักษา output: อย่าเชื่อคำตอบของ model (LLM05)
บท4 สร้าง guard ตัวแรกที่ห่อ นอก UseFunctionInvocation() แล้วรักษา input ของผู้ใช้ ก่อน loop จะ dispatch tool ได้ นั่นคือครึ่งซ้ายของ wrapper บทนี้หันไปครึ่งขวา — จังหวะที่ loop จบและ output กำลังจะไหลออก และมันคือจังหวะที่หลายคนมองข้ามที่สุด เพราะรู้สึกว่า “output ก็มาจาก model ของเราเอง จะน่ากลัวอะไร”
คำตอบคือ: output ของ model ไม่ใช่ ของเรา มันคือสิ่งที่ผู้โจมตี คุมได้ — เพราะเนื้อหาที่ป้อนเข้าไป (input ของผู้ใช้, ผลที่ tool คืนมา, memory/retrieval ของ #16) ล้วนมีคนอื่นเป็นต้นเรื่องได้ทั้งนั้น เมื่อ injection ลอดชั้น input ของบท4 เข้ามาได้ (และ honesty spine ของคอร์สยืนยันว่ามันลอดได้เสมอ) ร่องรอยของมันจะโผล่ที่ output — คำสั่ง refund ที่ model ไม่ควรเสนอ, ข้อมูลลูกค้าคนอื่นที่หลุดออกมา, secret ที่ติดมากับ tool result บทนี้วางสามชั้นดักตรงนี้: validate → redact → egress
code guard ในบทนี้ต่อยอด agent Order ตัวเดิมจากคอร์ส #15 (context layer จาก #16, resilient runtime จาก #17) — repo kaen-food-ordering (กำลังจัดทำ) เรายังห่อชั้น guard คร่อม IChatClient ตัวเดิม ไม่เพิ่ม tool หรือ store ใหม่ ทุกกติกา API ฝั่งแชตจาก #15 ยังยึดเดิม โดยเฉพาะข้อที่จ่ายคืนหนักที่สุดในบทนี้: อ่านคำตอบผ่าน ChatResponse.Messages[].Contents[] ไม่ใช่ ChatResponse.Text — เพราะ redactor ที่ scrub แค่ .Text จะพลาด secret ที่ซ่อนใน tool-call argument และ tool result
LLM05: output ของ model คือ untrusted data
หัวข้อที่มีชื่อว่า “LLM05: output ของ model คือ untrusted data”Improper Output HandlingImproper Output HandlingOWASP LLM05 — การ validate/sanitize/handle output ของ model ไม่พอ 'ก่อน' ส่งต่อไป component และระบบอื่น หลักคือ zero-trust: 'treat the model as any other user' ปฏิบัติต่อ output เป็น untrusted — validate ก่อนป้อนเข้า function backend, encode/escape ตามบริบทปลายทาง ('HTML encoding for web content, SQL escaping for database queries') ไม่เคยส่ง output ดิบเข้า refund/DB call ตรง ๆProcess คือความเสี่ยง LLM05 ในแผนที่ OWASP — และนิยามของมันชี้ตรงไปที่ ช่องว่าง ที่บทนี้อุด (S5, verbatim):
“Improper Output Handling refers specifically to insufficient validation, sanitization, and handling of the outputs generated by large language models before they are passed downstream to other components and systems.”
keyword คือ “before they are passed downstream” — ก่อนที่ output จะถูกส่งต่อไปยังส่วนอื่น: ก่อน issueRefund จะรัน argument ที่ model เสนอ, ก่อนข้อความจะ render บน UI, ก่อนอะไรจะลง log หรือ DB จุดยืนที่ OWASP สั่งไว้ตรงๆ คือ zero-trust — ปฏิบัติกับ model เหมือนเป็น user แปลกหน้าคนหนึ่ง (S5, verbatim):
“Treat the model as any other user, adopting a zero-trust approach, and apply proper input validation on responses coming from the model to backend functions.”
นี่คือมุมกลับของบท4 อย่างสวยงาม: บท4 validate input จากผู้ใช้ ก่อนเข้า model บทนี้ validate output จาก model ก่อนเข้า backend — เพราะจากมุมของ issueRefund แล้ว model ก็คือ “ผู้ใช้” ที่กำลังยื่นคำสั่งเข้ามาเช่นกัน Output GuardrailOutput Guardrailชั้น guard ที่รักษา output ของ model 'ก่อน' ไหลไป downstream (ก่อน issueRefund รันบน argument, ก่อน render UI, ก่อนลง log/DB) — structured-output validation (deserialize เป็น T แล้ว validate เช่น refund amount เป็นบวกและ ≤ order total), PII/secret redaction ที่วนทั่ว Contents[], tool-output screening และ context-aware encoding ที่ sink ⚠️ streaming redaction เป็น best-effort เท่านั้น (secret คร่อม2 chunk ได้) — buffer เป็น ChatResponse เต็มก่อน scrub ถ้าต้องเข้มงวดArchitecture คือ wrapper ครึ่งขวานี้: code deterministic ที่รัน หลัง base.GetResponseAsync คืนค่า — หลัง loop ของ UseFunctionInvocation() resolve เสร็จ
สามชั้นของ output guard
หัวข้อที่มีชื่อว่า “สามชั้นของ output guard”บทนี้วางสามชั้น ทุกชั้นสร้างได้จริงใน C# และทุกชั้น ไม่สมบูรณ์ — นั่นคือเหตุผลที่ต้องมีครบสาม ไม่ใช่เลือกอันเดียว
ชั้น 1 — validate โครงสร้างด้วย structured output
หัวข้อที่มีชื่อว่า “ชั้น 1 — validate โครงสร้างด้วย structured output”อย่าปล่อยให้ model ส่ง free-form text เข้า issueRefund โดยตรง บังคับให้มันตอบเป็น schema ที่เข้มงวด แล้ว validate ด้วย code — orderId ตรง format, จำนวนเงินเป็น decimal บวกและ ไม่เกินยอดออเดอร์จริง วิธีบังคับ schema คือ GetResponseAsync<T> (structured output จาก ChatClientStructuredOutputExtensions, S33):
using Microsoft.Extensions.AI;
// จำกัดสิ่งที่ model "พูด" ได้เรื่อง refund ให้อยู่ใน schema เดียวpublic record RefundProposal(string OrderId, decimal Amount, string Reason);
var proposal = await client.GetResponseAsync<RefundProposal>(messages, options, ct);
// ❌ version ดิบ: ส่ง proposal.Result เข้า issueRefund ตรง ๆ — เชื่อ output โดยไม่ validate// ✅ validate แบบ deterministic ก่อนเสมอ:if (!OrderId.IsValid(proposal.Result.OrderId)) throw new GuardrailTrip("bad orderId");if (proposal.Result.Amount <= 0m || proposal.Result.Amount > orderTotal) throw new GuardrailTrip("amount out of bounds");การกำหนด schema ไม่ใช่แค่เรื่องความสะดวก — มันคือการ “Define and Validate Output Formats” ที่ OWASP LLM01 แนะนำไว้ (S3) injection ที่พยายามหลอกให้ model คืน refund 9999.00 บนออเดอร์ 250 บาทจะตกด่านนี้ด้วย code ไม่ต้องพึ่งว่า model จะ “ฉลาดพอ” ที่จะปฏิเสธเอง
ชั้น 2 — redact PII และ secret โดยวนทั่ว Contents[]
หัวข้อที่มีชื่อว่า “ชั้น 2 — redact PII และ secret โดยวนทั่ว Contents[]”ชั้นนี้คือหัวใจของ PII RedactionPII Redactionการรักษาข้อมูลส่วนบุคคล (email, เลขบัตร/PAN, เบอร์โทร, ที่อยู่, รูปแบบ API-key) ออกจากข้อความก่อนออกจาก process — ทั้ง reply ถึงผู้ใช้และ log ⚠️ security corollary (invariant #5): ต้องวนทั่ว Messages[].Contents[] ต่อ message ไม่ใช่แค่ convenience string .Text เพราะ secret ซ่อนใน tool-call arguments / tool results ได้ด้วย เป็นหนึ่งชั้น จับได้เฉพาะ pattern ที่ enumerate ไว้ — format exfil แปลกใหม่ยังลอดได้Process และคือจุดที่ corollary ด้านความปลอดภัยของ invariant #5 จ่ายคืนเต็มๆ OWASP LLM02 ให้ตัวอย่างความล้มเหลว canonical ของ agent แบบ Order พอดี (S4, verbatim):
“A user receives a response containing another user’s personal data due to inadequate data sanitization.”
redactor วนทุก TextContent แล้ว scrub รูปแบบที่ enumerate ไว้ — email, เลขบัตร/PAN, เบอร์โทร, ที่อยู่, รูปทรง API key — ก่อน ข้อความออกจาก process ทั้งฝั่ง reply ให้ผู้ใช้และฝั่ง log สิ่งที่พลาดกันบ่อยคือวนแค่ .Text:
static void Sanitize(ChatResponse response){ foreach (var msg in response.Messages) for (int i = 0; i < msg.Contents.Count; i++) if (msg.Contents[i] is TextContent t) msg.Contents[i] = new TextContent(PiiRedactor.Scrub(t.Text));
// สำคัญ: secret ไม่ได้อยู่แค่ใน TextContent — ต้องตรวจ // FunctionCallContent.Arguments และ FunctionResultContent.Result ด้วย // redactor ที่ scrub แค่ response.Text (convenience concat) จะพลาดมันทั้งหมด}ทำไมต้องวน Contents[] ไม่ใช่ .Text? เพราะ ChatResponse.Text เป็นแค่ convenience concatenation ของ text block — secret ที่ agent ส่งเป็น argument ของ tool call (เช่น token ที่ติดมาใน issueRefund argument) หรือที่โผล่ใน tool result จะ ไม่ อยู่ใน .Text เลย redactor ที่มองแค่ .Text จึงปล่อยให้ความลับหลุดผ่านช่องที่มันมองไม่เห็น — นี่คือ invariant ที่ “ship-wrong-code-if-you-miss-it” ของทั้งคอร์ส
ชั้น 3 — egress control: screen ผลของ tool ก่อน model ลงมือ
หัวข้อที่มีชื่อว่า “ชั้น 3 — egress control: screen ผลของ tool ก่อน model ลงมือ”นี่คือด่านที่รับมือ indirect injection โดยตรง getOrder/getDeliveryStatus คืนข้อมูลจาก DB/third-party ที่ผู้อื่นแก้ได้ ก่อนป้อนกลับเข้า model ต้องทำสองอย่าง (S13):
หนึ่ง — ส่งเนื้อหา untrusted เฉพาะใน tool_result block แบบ JSON-encoded เท่านั้น ไม่ splice เข้า system prompt หรือ user text ธรรมดา (JSON escaping ให้ delimiter ที่ชัดเจน ผู้โจมตีจึงปิด quote เพื่อ “break out” ไปเป็น instruction ไม่ได้) สอง — screen ผลของ tool ด้วย classifier เบาๆ ก่อน model ลงมือ (S13, verbatim):
“Screen tool outputs before Claude acts on them. Apply the same lightweight-model screening pattern you use for user input to the content your tools return… only return the content as a tool_result block if the screen reports no injection attempt.”
สังเกตว่านี่คือ pattern เดียวกับ input screen ของบท4 แค่ย้ายมาใช้กับ ผลของ tool — reuse ได้เลย ถ้า screen รายงานว่าพบ injection ก็คืน summary ที่ถูกตัดทอนหรือ error แทนเนื้อหาดิบ
ที่ sink: encode ตาม context ปลายทาง
หัวข้อที่มีชื่อว่า “ที่ sink: encode ตาม context ปลายทาง”output ที่ผ่านสามชั้นแล้วยังต้องถูก encode ให้ถูกกับที่ที่มันจะไปลง OWASP LLM05 สั่งไว้ตรงๆ (S5, verbatim):
“Implement context-aware output encoding based on where the LLM output will be used (e.g., HTML encoding for web content, SQL escaping for database queries).”
reply ที่จะ render บนเว็บต้อง HTML-encode (กัน XSS ที่ model ถูกหลอกให้ผลิต <script>), query ต้อง parameterize เสมอ — จุดนี้คือวินัย output-handling พื้นฐานที่ไม่เปลี่ยนไปเพราะต้นทางเป็น LLM มันยิ่ง ต้อง ทำเพราะต้นทางเป็น LLM ที่ผู้โจมตีคุมได้
redact ทีละ chunk บน ChatResponseUpdate เป็น best-effort — secret หนึ่งก้อนอาจถูกหั่นคร่อม2 chunk แล้วลอด regex ที่มองทีละ chunk ไปได้ ถ้าต้องการ redact แบบเข้มงวด ให้ buffer stream ทั้งก้อนเป็น ChatResponse ก่อนแล้วค่อย scrub ทีเดียว — แลก latency กับความปลอดภัย:
ChatResponse full = await updates.ToChatResponseAsync(); // buffer stream ทั้งก้อน (updates เป็น IAsyncEnumerable)Sanitize(full); // scrub ครั้งเดียว เห็นข้อความครบพูดตรงๆ กับผู้ใช้ปลายทาง: ถ้าเลือก stream เพื่อ UX ก็ยอมรับว่า redaction หลวมลง ถ้าเลือกความเข้มงวดก็ยอมรับว่า reply มาเป็นก้อนช้าลง — ไม่มีทางได้ทั้งสองอย่างเต็มร้อย
ลำดับ pipeline: output guard รัน หลัง loop resolve
หัวข้อที่มีชื่อว่า “ลำดับ pipeline: output guard รัน หลัง loop resolve”ย้ำ mental model จากบท1/บท4 — guard คือ wrapper และ ลำดับคือการตัดสินใจด้านความปลอดภัยที่ load-bearing (S33): guard ห่อ นอก UseFunctionInvocation() ดังนั้น input ถูก validate ก่อน loop dispatch tool ได้ (บท4) และ output ถูก scrub หลัง loop resolve เสร็จ (บทนี้) โครง DelegatingChatClient ตัวเดิมจากบท4 แบกทั้งสองครึ่งไว้แล้ว:
public override async Task<ChatResponse> GetResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken ct = default){ InputGuard.Validate(messages); // บท4 — ก่อน loop var response = await base.GetResponseAsync(messages, options, ct) // loop ของ #15 หมุนที่นี่ .ConfigureAwait(false); OutputGuard.Sanitize(response); // บทนี้ — หลัง loop: validate + redact ทั่ว Contents[] return response;}IChatClient client = anthropic.AsIChatClient("claude-opus-4-8") // beta bridge, bare id (invariant #4/#6) .AsBuilder() .UseGuardrails() // OUTERMOST: เห็น input ก่อน, เห็น output หลังสุด .UseFunctionInvocation() // loop #15: getOrder/getDeliveryStatus/issueRefund .Build();ทุก invariant ฝั่งแชตจาก #15 ยังยึดเดิม: header x-api-key ไม่ใช่ Authorization: Bearer · anthropic-version: 2023-06-01 · ห้ามส่ง temperature/top_p/top_k · model id เปล่า claude-opus-4-8 · อ่านผ่าน Messages[].Contents[] ไม่ใช่ .Text
มี classifier สำเร็จรูปที่ช่วย screen output ได้: Azure AI Content Safety (Prompt Shields สำหรับ input/XPIA; หมวด classic ผ่าน Azure.AI.ContentSafety 1.0.0 GA แต่ Prompt Shields ใหม่กว่า SDK GA จึงต้องเรียกผ่าน REST/preview), Llama Guard 3 (classify ทั้ง input และ response), OpenAI Moderation / NeMo Guardrails ทั้งหมดนี้ ลด ความเสี่ยง แต่ไม่มีตัวไหนแทน defense-in-depth ได้ — เป็นชั้นเสริม ไม่ใช่ชั้นเดียว
แผนภาพ: output → validate → redact → egress
หัวข้อที่มีชื่อว่า “แผนภาพ: output → validate → redact → egress”flowchart TD OUT["output ของ model<br/>ถือว่า untrusted (คุมโดยผู้โจมตีได้)"] OUT --> V["ชั้น 1 · validate โครงสร้าง<br/>structured output + ตรวจ schema/cap"] V -->|"ผ่าน schema"| R["ชั้น 2 · redact<br/>วนทุก content ลบ PII และ secret"] V -->|"ผิด schema หรือเกิน cap"| TRIP["tripwire<br/>ปฏิเสธ + log"] R --> E["ชั้น 3 · egress control<br/>screen ผลของ tool ก่อน model ลงมือ"] E -->|"สะอาด"| SINK["sink ปลายทาง<br/>reply · log · issueRefund + encode ตาม context"] E -->|"พบ injection"| TRIP classDef danger fill:#b91c1c,stroke:#7f1d1d,color:#f8fafc; classDef guard fill:#475569,stroke:#1e293b,color:#f8fafc; class OUT,TRIP danger; class V,R,E guard;
คำบรรยายภาพ: output ที่ออกจาก loop ถือเป็น untrusted (ช่องแดงบน) แล้วไหลผ่านสามชั้น guard สีเทา — validate โครงสร้าง, redact PII/secret ทั่วทุก content, egress control ที่ screen ผลของ tool — ก่อนถึง sink ปลายทางที่ยัง encode ตาม context อีกชั้น สิ่งที่ผิด schema/เกิน cap หรือพบ injection จะตกลง tripwire (ช่องแดงล่าง) ไม่มีชั้นใดชั้นเดียวสมบูรณ์ แต่ซ้อนกันจึงลดความเสี่ยงและกันความเสียหาย
บทนี้ ไม่ใช่ #14/#17/#13 (anti-overlap)
หัวข้อที่มีชื่อว่า “บทนี้ ไม่ใช่ #14/#17/#13 (anti-overlap)”- vs #14 (designing MCP servers) — #14 รักษา tool-server / protocol MCP บทนี้คือ agent-level output defense ที่ยืนได้ แม้ tool/server เชื่อถือได้เต็มร้อย — เพราะ data ที่ tool สะอาดคืนมาก็เป็นผู้โจมตีได้ (indirect injection) egress control ชั้น 3 คือด่านที่รับมือเรื่องนี้ที่ระดับ agent
- vs #17 (robustness) — #17 validate รูปทรง ของ output เพื่อความ ถูกต้อง (parse ไม่พัง, field ครบ) บทนี้ validate output เดียวกันในฐานะ ศัตรู — schema ที่ valid แต่ refund เกินยอด ผ่าน #17 แต่ต้องตกด่านนี้
- vs #13 (evals) — redaction ที่นี่ ลงมือ ตัดข้อมูลออกจริง ไม่ใช่ วัด อัตราการรั่ว การวัดว่า redactor พลาดกี่เปอร์เซ็นต์คืองานของ eval harness ใน #13 — บทนี้ผลิตเคสให้ #13 ไปวัด ไม่สอน scoring ซ้ำ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”output ของ model คือสิ่งที่ผู้โจมตีคุมได้ — LLM05 สั่งให้ zero-trust มันเหมือน user แปลกหน้า บทนี้วางสามชั้นก่อน output ถึงปลายทาง: validate โครงสร้างด้วย structured output + ตรวจ cap ด้วย code, redact PII/secret โดยวนทั่ว Contents[] (ไม่ใช่แค่ .Text — corollary ที่พลาดไม่ได้), และ egress control ที่ screen ผลของ tool ก่อน model ลงมือ ปิดท้ายด้วย encode ตาม context ที่ sink และยอมรับตรงๆ ว่า streaming redact ได้แค่ best-effort
แต่ validation จับได้แค่ รูปทรง ที่เราคาดไว้ redaction จับได้แค่ pattern ที่เรา enumerate — รูปแบบ exfil ใหม่ หรือ refund ที่ valid ตาม schema แต่ยังผิดเจตนา ก็ยังลอดได้ นั่นคือเหตุผลว่าทำไมบท6 ถึงต้องมา: least-privilege + human-approval gate ที่ หด blast radius เมื่อ detection ของบท4/5 พลาด — เพราะ Anthropic วัดการป้องกันที่ดีที่สุดของตัวเองแล้วยังสรุปว่า (S14, verbatim):
“A 1% attack success rate — while a significant improvement — still represents meaningful risk. No browser agent is immune to prompt injection.”
ความปลอดภัยคือกระบวนการ ไม่ใช่ checkbox — output guard ลด ความเสี่ยงและ ดัก สิ่งที่ลอดชั้น input มา แต่ไม่ กำจัด ภัย บท6 จะ กัน ความเสียหายที่เหลือ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- OWASP, “LLM05:2025 Improper Output Handling” (2025) — นิยาม “insufficient validation, sanitization, and handling of the outputs… before they are passed downstream”, zero-trust “Treat the model as any other user”, และ context-aware output encoding (S5)
- OWASP, “LLM02:2025 Sensitive Information Disclosure” (2025) — เคส canonical “A user receives a response containing another user’s personal data due to inadequate data sanitization” (S4)
- OWASP, “LLM01:2025 Prompt Injection” (2025) — คำแนะนำ “Define and Validate Output Formats” ที่หนุนการ validate schema ของ output ด้วย code (S3)
- Anthropic, “Mitigate jailbreaks and prompt injections” (accessed 2026-07-19) — egress guard “Screen tool outputs before Claude acts on them” และการส่ง untrusted content เฉพาะใน
tool_resultแบบ JSON-encoded (S13) - Anthropic, “Mitigating the risk of prompt injections in browser use” (2025-11-24) — honesty: “A 1% attack success rate … still represents meaningful risk. No browser agent is immune to prompt injection.” (S14)
- Microsoft Learn, “DelegatingChatClient” (page upd. 2026-07-01, pkg v10.7.0) — base ของ guard wrapper ที่รัน
Sanitizeหลังbase.GetResponseAsync(S29) - Microsoft Learn, “Use the IChatClient interface” (upd. 2026-03-13) — structured output
GetResponseAsync<T>, custom middleware,ChatClientBuilder.Use, ลำดับ pipeline (S33)
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทำไม redactor ของบทนี้ต้องวน response.Messages[].Contents[] ไม่ใช่ scrub แค่ ChatResponse.Text?