Orkas Orkas
ดาวน์โหลด GitHub
หน้าแรก บล็อก สถาปัตยกรรม
สถาปัตยกรรม

ชั้นที่เปลี่ยนโมเดลให้เป็นผลิตภัณฑ์: วิศวกรรมโครงสร้างควบคุมเอเจนต์ของ Orkas

Orkas เปลี่ยนการเรียกใช้โมเดลให้เป็นรันไทม์เอเจนต์บนเดสก์ท็อปที่เชื่อถือได้อย่างไร: ลูปการทำงานแบบสตรีม การกำหนดเส้นทางเครื่องมือ การย่อบริบท ชั้นนามธรรมสำหรับผู้ให้บริการ หน่วยความจำ และเซสชันที่ทนต่อการหยุดทำงานกะทันหัน

ใครก็ตามที่เคยส่งมอบผลิตภัณฑ์เอเจนต์ย่อมรู้ความรู้สึกนี้: ทำเดโมให้ใช้ได้นั้นเร็ว แต่เปลี่ยนให้เป็นสิ่งที่ผู้ใช้ไว้ใจได้ทุกวัน รันทั้งวันบนเครื่องของพวกเขาเองโดยไม่ล่มนั้นยาก และส่วนที่ยากไม่ใช่การเชื่อมโมเดล แต่เป็นทั้งชั้นที่ล้อมรอบโมเดลอยู่

ชั้นนี้มีหลายชื่อ ชื่อที่ผมจะใช้คือ Agent Harness มันอยู่ระหว่าง “โมเดลขนาดใหญ่” กับ “ฟีเจอร์ผลิตภัณฑ์” และเป็นรันไทม์ตัวจริง: เปลี่ยนคำขอเดียวของผู้ใช้ให้เป็นการสนทนากับโมเดลหลายรอบ แทรกการเรียกเครื่องมือระหว่างรอบ ส่งผลลัพธ์กลับ ย่อบริบทก่อนล้น ลองใหม่เมื่อเครือข่ายสะดุด และกู้คืนบทสนทนาแม้กระบวนการจะล่ม โมเดลทำหน้าที่คิด ส่วนฮาร์เนสทำให้ความคิดนั้นกลายเป็นลำดับการกระทำที่เชื่อถือได้

Orkas เป็นแอปเอเจนต์บนเดสก์ท็อปที่รันในเครื่องของผู้ใช้เอง และฮาร์เนสทั้งหมดอยู่ฝั่งไคลเอนต์ บทความนี้จะพาดูว่าชั้นดังกล่าวสร้างขึ้นอย่างไร แบ่งเป็นชั้นอย่างไร ลูปการทำงานมีหน้าตาแบบไหน ทำชั้นนามธรรมของเครื่องมือและโมเดลอย่างไร และจัดการความจำกับเซสชันอย่างไร รายละเอียดโค้ดถูกตัดข้อมูลเฉพาะและปรับให้เป็นภาพทั่วไปแล้ว แต่โครงสร้างทางวิศวกรรมเป็นของจริง

สรุปสั้น ๆ ฮาร์เนสคือส่วนที่คุณติดตั้งจริง ทุกอย่างที่อธิบายที่นี่อยู่ในแอปเดสก์ท็อป เป็นชั้นเดียวกับที่รันเอเจนต์ของคุณในเครื่อง โดยมีซอร์สโค้ดบน GitHub
ดาวน์โหลด Orkas — ฟรี

ชั้นต่าง ๆ

หากแผ่โครงสร้างผลิตภัณฑ์เอเจนต์ออกมา จะได้ชั้นโดยประมาณต่อไปนี้ เรียงจากล่างขึ้นบน:

┌─────────────────────────────────────────┐
│  ฟีเจอร์ผลิตภัณฑ์  (แชต / ทักษะ / ตัวเชื่อมต่อ / ซิงค์)  │
├─────────────────────────────────────────┤
│  Agent Harness  (ลูปการทำงาน / เครื่องมือ / เซสชัน)  │
├─────────────────────────────────────────┤
│  ชั้นนามธรรมของผู้ให้บริการ  (รวมผู้ให้บริการ LLM หลายราย)  │
├─────────────────────────────────────────┤
│  โครงสร้างพื้นฐาน  (ชนิดข้อมูล / ข้อผิดพลาด / บันทึก / การตั้งค่า)  │
└─────────────────────────────────────────┘

มีการตัดสินใจสำคัญข้อหนึ่งฝังอยู่ในโครงสร้างนี้: การอนุมานของโมเดลทั้งหมดเกิดขึ้นบนไคลเอนต์. แอปเดสก์ท็อปไม่ใช่ไคลเอนต์แบบบาง แต่มีฮาร์เนสอยู่ภายในและเรียกโมเดลโดยตรง เซิร์ฟเวอร์จัดการเฉพาะบัญชี การซิงค์หลายอุปกรณ์ และการเรียกเก็บเงิน โดยไม่ได้รันเอเจนต์เลย การตัดสินใจนี้กำหนดแทบทุกอย่างที่ตามมา: เซสชันถูกเก็บบนดิสก์ภายในเครื่อง เครื่องมือทำงานโดยตรงกับไดเรกทอรีทำงานของผู้ใช้ และข้อมูลอ่อนไหวไม่ออกจากเครื่อง

ตัวฮาร์เนสแบ่งเป็นหลายส่วน ได้แก่ ลูปการทำงาน (ตัวรัน) เซสชัน เครื่องมือ ชั้นผู้ให้บริการ และความจำ มาดูทีละส่วนกัน

ลูปการทำงาน: เจเนอเรเตอร์แบบสตรีมมิง

หัวใจของฮาร์เนสคือตัวรัน สรุปหน้าที่ในประโยคเดียวคือ: คุยกับโมเดลซ้ำไปเรื่อย ๆ จนโมเดลบอกว่า “เสร็จแล้ว”

มันถูกพัฒนาเป็นเจเนอเรเตอร์แบบอะซิงโครนัส และการเลือกแบบนี้มีความสำคัญ การรันเอเจนต์หนึ่งครั้งมีมากกว่า “ส่งคำขอแล้วรอผล” ระหว่างทางมีหลายอย่างเกิดขึ้น โมเดลกำลังส่งโทเคน ต้องการเรียกเครื่องมือ เครื่องมือทำงานเสร็จ บริบทยาวจนต้องย่อ เครือข่ายล้มเหลวและกำลังลองใหม่ หากใช้คอลแบ็กหรือ Promise ธรรมดา การแสดงสถานะระหว่างทางเหล่านี้ให้ผู้เรียกเห็นอย่างเป็นระบบทำได้ยาก แต่เมื่อเป็นเจเนอเรเตอร์ ทั้งหมดจะกลายเป็นสตรีมของเหตุการณ์ที่ส่งออกด้วย yieldดังนี้:

type AgentRunEvent =
  | { type: "text_delta"; text: string }              // model emitting tokens
  | { type: "tool_start"; name: string; input: unknown } // a tool starts executing
  | { type: "tool_end"; name: string; result: string }   // a tool finished
  | { type: "compaction"; tokensBefore: number; tokensAfter: number } // context compacted
  | { type: "retry"; attempt: number; reason: string }   // error, retrying
  | { type: "done"; result: AgentRunResult }             // terminal

UI สมัครรับสตรีมเหตุการณ์นี้และแสดงผลลัพธ์ของโมเดลกับการทำงานของเครื่องมือแบบเรียลไทม์ ภายในนั้น จุดเรียกใช้งานแบบไม่สตรีมก็เพียง “อ่านสตรีมจนจบ แล้วรับเหตุการณ์สุดท้าย done” จุดเรียกใช้ทั้งสองใช้การทำงานชุดเดียวกัน จึงไม่มีเส้นทางโค้ดชุดที่สองที่อาจคลาดเคลื่อนจนไม่สอดคล้องกัน

สิ่งที่เกิดขึ้นภายในหนึ่งเทิร์น

เมื่อแจกแจงออกมา หนึ่งเทิร์นมีลักษณะประมาณนี้:

  1. เพิ่มข้อความของผู้ใช้ ซึ่งอาจมีภาพ ลงในประวัติเซสชัน
  2. ประกอบพรอมป์ต์ระบบ โดยใส่เครื่องมือที่พร้อมใช้งานในขณะนั้น ดัชนีทักษะ และข้อมูลอื่น ๆ
  3. แยกวิเคราะห์สตริงโมเดลแล้วระบุผู้ให้บริการและรหัสโมเดลที่แน่นอน
  4. แปลงเครื่องมือทั้งหมดเป็นคำจำกัดความที่โมเดลเข้าใจ แล้วส่งไปพร้อมประวัติ
  5. อ่านสตรีมคำตอบของโมเดล yieldข้อความทีละโทเคน พร้อมรวบรวมการเรียกเครื่องมือที่โมเดลส่งมา
  6. เมื่อสตรีมสิ้นสุด ให้ดูเหตุผลที่โมเดลหยุด:
  • หากเป็น tool_use, แสดงว่าโมเดลต้องการเรียกเครื่องมือ ให้รันเครื่องมือ แล้วกลับไปขั้นตอนที่ 5 เพื่อถามโมเดลอีกครั้ง
  • มิฉะนั้นเทิร์นจะสิ้นสุด ให้ประกอบผลลัพธ์ yield done, แล้วคืนค่ากลับ

มีเงื่อนไขคงที่ข้อหนึ่งที่ต้องรักษาไว้: การเรียกเครื่องมือทุกครั้งของโมเดลต้องตามด้วยผลลัพธ์เครื่องมือที่ตรงกันทันทีในประวัติ. API ของโมเดลบังคับการจับคู่นี้อย่างเคร่งครัด หากผิดเงื่อนไข คำขอถัดไปจะเกิดข้อผิดพลาดหรือค้าง เราจะกลับมาพูดถึงเรื่องนี้เมื่ออธิบายการซ่อมแซมเซสชันด้วยตัวเอง

การเรียกเครื่องมือถูกส่งกลับไปจัดการอย่างไร

โมเดลไม่ได้รันเครื่องมือเอง เพียงบอกว่า “ฉันต้องการเรียก read_file ด้วยอาร์กิวเมนต์เหล่านี้” เมื่อตัวรันรับความตั้งใจนั้นแล้ว:

for (const call of toolUseBlocks) {
  yield { type: "tool_start", name: call.name, input: call.input };

  const tool = this.tools.get(call.name);
  const ctx = { workingDir, signal, state: { sandboxEnv } };
  const result = await tool.execute(call.input, ctx);

  // append the result to the session as a tool-result message
  session.addToolResult(call.id, result);

  yield { type: "tool_end", name: call.name, result: result.content };
}

เครื่องมือทำงานตามลำดับ ผลลัพธ์จะถูกเขียนกลับลงในประวัติตามลำดับที่โมเดลระบุไว้ จากนั้นระบบจะถามโมเดลอีกครั้งพร้อมผลลัพธ์เหล่านั้น เมื่อเห็นผลลัพธ์แล้ว โมเดลอาจเรียกเครื่องมืออื่นต่อหรือให้คำตอบสุดท้ายเลย วงจร “ถาม → เรียกใช้ → ตอบ → ถามอีกครั้ง” นี้เองที่ทำให้เอเจนต์ทำงานหลายขั้นตอนจนสำเร็จได้

มีรายละเอียดหนึ่งที่ควรกล่าวถึงโดยเฉพาะ: เครื่องมือบางอย่างส่งคืนรูปภาพ เช่น ภาพหน้าจอหรือภาพที่สร้างขึ้น แต่โมเดลจำนวนมากไม่รองรับรูปภาพในช่องทางผลลัพธ์ของเครื่องมือ Orkas จัดการเรื่องนี้โดยแยกรูปภาพออกเป็นข้อความจากผู้ใช้อีกข้อความหนึ่ง แล้ววางไว้ หลัง ผลลัพธ์ของเครื่องมือ โมเดลจึงอ่านว่า “เครื่องมือส่งคืนข้อความนี้” ก่อน แล้วเห็นรูปภาพที่เกี่ยวข้องในข้อความถัดไปทันที เป็นการประนีประนอมเล็กน้อยเพื่อรองรับความสามารถที่ต่างกันของผู้ให้บริการ

ทำอย่างไรเมื่อบริบทใกล้ล้น

อุปสรรคที่งานระยะยาวเจอบ่อยที่สุดคือขนาดหน้าต่างบริบท Orkas ไม่รอจนเต็ม แต่กำหนด เกณฑ์ไว้ที่ 60%: หลังจากเครื่องมือทำงานแต่ละรอบ ระบบจะประเมินว่าโทเคนปัจจุบันใช้พื้นที่หน้าต่างบริบทไปเท่าใด และเมื่อเกิน 60% ก็จะเริ่มย่อบริบทล่วงหน้า

การย่อบริบททำโดยขอให้โมเดลสรุปบทสนทนาก่อนหน้า แล้วแทนที่ข้อความเก่าด้วยสรุปนั้น โดยเก็บข้อความช่วงท้ายล่าสุดไว้ ฟังดูง่าย แต่มีจุดที่ต้องระวัง: หลังแทนที่แล้ว ช่วงท้ายที่เก็บไว้ต้องไม่เริ่มด้วย “ผลลัพธ์เครื่องมือที่ไร้คู่” กล่าวคือ ต้องไม่มี “ผลลัพธ์ที่ไม่มีการเรียกใช้ที่ตรงกัน” ไม่เช่นนั้นก็จะละเมิดข้อกำหนดการจับคู่อีกครั้ง ตรรกะการย่อบริบทจึงต้องแน่ใจว่าตัดตรงขอบเขตที่ไม่ทำให้คู่แยกจากกัน

มีการตัดสินใจที่น่าสนใจกว่านั้นและควรอธิบาย: ทำไมจึงเลือกวิธีคร่าว ๆ อย่าง “สรุปทั้งช่วงเมื่อถึง 60%” แทนวิธีละเอียดกว่า เช่น ให้คะแนนแต่ละข้อความแล้วตัดตามความสำคัญ สกัดข้อมูลแบบมีโครงสร้างจากผลลัพธ์เครื่องมือ หรือเก็บความจำเป็นต้นไม้หลายชั้น? วิธีเหล่านั้นดูดีในงานวิจัย แต่เราตั้งใจไม่เลือกแนวทางนั้นด้วยเหตุผลสามข้อ

ข้อแรก การแคช แคชพรอมป์ต์ของโมเดลจับคู่จากส่วนต้น: ตราบใดที่ส่วนต้นของประวัติไม่เปลี่ยน ช่วงนั้นก็ใช้แคชได้ ช่วยประหยัดทั้งเงินและเวลารอ การย่อบริบทแบบละเอียดจะแก้ไขช่วงกลางของประวัติอยู่ตลอด ทำให้ส่วนต้นที่แคชไว้เสียไปซ้ำ ๆ ทุกครั้งที่แก้ต้องประมวลผลอินพุตจำนวนมากใหม่ กลยุทธ์ “ปล่อยไว้ แล้วค่อยย่อครั้งเดียวเมื่อถึงเกณฑ์” ช่วยให้ส่วนต้นคงเดิมในรอบส่วนใหญ่ โดยมีเพียงการย่อครั้งนั้นที่ทำให้แคชใช้ไม่ได้ จึงเป็นมิตรกับแคชกว่ามาก

ข้อที่สอง ความซับซ้อน ข้อกำหนดที่เราย้ำอยู่เสมอว่า “การเรียกเครื่องมือทุกครั้งต้องมีผลลัพธ์คู่กัน” ยิ่งตัดประวัติอย่างละเอียด ก็ยิ่งมีโอกาสละเมิดกฎนี้ในกรณีใดกรณีหนึ่ง การสรุปทั้งช่วงต้องดูแลเพียงจุดตัดเดียวที่ไม่แยกคู่ จำนวนจุดที่อาจพลาดจึงน้อยลงถึงระดับสิบเท่า กรณีขอบที่ลดลงหนึ่งประเภทก็คือเหตุขัดข้องในระบบใช้งานจริงที่ลดลงหนึ่งประเภท

ข้อที่สาม อาศัยประโยชน์จากโมเดลที่ดีขึ้น ในช่วงสองสามปีที่ผ่านมา หน้าต่างบริบทขยายขึ้นอย่างต่อเนื่อง และโมเดลก็จัดการบริบทยาวได้ดีขึ้นเรื่อย ๆ การทุ่มแรงสร้างอัลกอริทึมย่อบริบทอันซับซ้อนในวันนี้ เท่ากับกำลังต่อสู้กับปัญหาที่เล็กลงเรื่อย ๆ มีโอกาสที่พอปรับแต่งเสร็จ โมเดลรุ่นถัดไปก็เพิ่มหน้าต่างบริบทเป็นสองเท่าพอดี แล้วความซับซ้อนที่สร้างไว้ก็กลายเป็นภาระล้วน ๆ ในทางกลับกัน การให้โมเดลสรุปเองจะดีขึ้นโดยอัตโนมัติตามโมเดล: ยิ่งโมเดลเลือกสิ่งสำคัญได้เก่ง สรุปก็ยิ่งมีคุณภาพ โดยเราไม่ต้องแก้โค้ดสักบรรทัด ความซับซ้อนที่โมเดลแบกรับแทนได้ ก็ไม่ควรเป็นสิ่งที่เราต้องแบกเอง

การประมาณโทเคนมีปัญหาหนึ่งที่มองข้ามได้ง่าย: ภาษาจีน หากประมาณภาษาจีนด้วยความคุ้นเคยจากภาษาอังกฤษ ซึ่งคิดคร่าว ๆ ว่าอักขระไม่กี่ตัวเท่ากับหนึ่งโทเคน จะนับได้น้อยกว่าจริงมาก Orkas ให้น้ำหนักอักขระ CJK แยกต่างหากในการประมาณ มิฉะนั้นเกณฑ์ของบทสนทนาภาษาจีนล้วนจะคลาดเคลื่อน และการย่อบริบทจะไม่ทำงานเมื่อควรทำ

ข้อผิดพลาดและการลองใหม่

เมื่อทำงานบนเครื่องของผู้ใช้และพึ่งพา API โมเดลภายนอก ข้อผิดพลาดเป็นเรื่องปกติ ไม่ใช่ข้อยกเว้น ตัวรันแบ่งข้อผิดพลาดเป็นสองสามประเภทและจัดการแต่ละประเภทต่างกัน:

  • ลองใหม่ได้: การจำกัดอัตราคำขอ การหมดเวลา การเชื่อมต่อหลุด และ 5xx ใช้การหน่วงเวลาที่เพิ่มขึ้นแบบเอ็กซ์โพเนนเชียลพร้อมสุ่มเวลาเล็กน้อย โดยจำกัดไว้ที่ 30 วินาที หากเป็นการจำกัดอัตราคำขอและเซิร์ฟเวอร์ส่ง retry-afterมา ก็ทำตามนั้น
  • ลองใหม่ไม่ได้: เช่น การยืนยันตัวตนล้มเหลว ลองใหม่กี่ครั้งก็ไม่ช่วย จึงรายงานข้อผิดพลาดทันที
  • กรณีพิเศษ: บริบทล้น ลองย่อบริบทก่อน แล้วลองใหม่อีกหนึ่งครั้ง จะรายงานข้อผิดพลาดก็ต่อเมื่อยังล้มเหลว

ยังมีอีกประเภทหนึ่งคือ “ตัวเครื่องมือเองทำงานล้มเหลว” ซึ่งไม่ทำให้ทั้งรอบล้มไปด้วย เพราะความล้มเหลวของเครื่องมือก็เป็นข้อมูลให้โมเดล เมื่อเห็นว่า “คำสั่งนั้นเกิดข้อผิดพลาด” โมเดลก็สามารถลองวิธีอื่นได้ตามปกติ ระบบควบคุมการทำงานแยกข้อผิดพลาดชั่วคราวของเครื่องมือเหล่านี้ออกจากความผิดพลาดจริง โดยไม่ขัดจังหวะกระบวนการและไม่ทิ้งข้อมูลเหล่านี้ แต่จะแสดงไว้ในสถิติย้อนหลัง (ข้อมูลนี้จะถูกนำไปใช้ในกลไกพัฒนาตัวเอง ซึ่งเป็นหัวข้อของบทความถัดไป)

สัญญาณยกเลิกจากภายนอก (AbortSignal) จะถูกตรวจสอบในทุกจุดสำคัญ เมื่อผู้ใช้กด “หยุด” รอบปัจจุบันจะหยุดทันที และจะไม่เริ่มการลองใหม่เพิ่มเติม

ชั้นนามธรรมของเครื่องมือ: เรียบง่ายพอให้ขยายต่อ

อินเทอร์เฟซของเครื่องมือถูกออกแบบให้เรียบง่ายโดยตั้งใจ:

interface AgentTool {
  readonly name: string;
  readonly description: string;          // shown to the model
  readonly inputSchema: Record<string, unknown>;  // JSON Schema to constrain inputs
  execute(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}

เครื่องมือเป็นเพียง “ชื่อ + คำอธิบายให้โมเดล + สคีมาอินพุต + ฟังก์ชันทำงาน” เครื่องมือในตัว ได้แก่ อ่านไฟล์ เขียนไฟล์ รันคำสั่งเชลล์ ค้นหาเว็บ และดึงข้อมูลเว็บ ล้วนใช้อินเทอร์เฟซนี้ ชั้นเดสก์ท็อปเพิ่มชุดเครื่องมือที่เน้นการใช้งานในเครื่องเข้ามาอีก เช่น ค้นหาฐานความรู้ สร้างภาพ และเรียกตัวเชื่อมต่อภายนอก แต่ยังใช้อินเทอร์เฟซเดียวกัน

ข้อดีของอินเทอร์เฟซที่เรียบง่ายคือ ตัวรันไม่ต้องสนใจว่าเครื่องมือมาจากไหน ไม่ว่าจะเป็นเครื่องมือในตัว ผู้ใช้กำหนดเอง หรือโหลดมาจากสกิล ทั้งหมดเป็นสิ่งชนิดเดียวกัน ลงทะเบียนไว้ใน Map<string, AgentTool> เดียวกัน และแปลงเป็นคำจำกัดความที่โมเดลอ่านได้ในแต่ละรอบ

เครื่องมือที่มีผลข้างเคียง เช่น คำสั่งเชลล์ จะทำงานผ่านตัวประมวลผลที่แยกไว้ โดยมีการหมดเวลา การจำกัดความยาวเอาต์พุต รายการคำสั่งต้องห้าม และส่งตัวแปรสภาพแวดล้อมเข้าไปแยกต่างหาก แทนการแก้สภาพแวดล้อมส่วนกลางของโปรเซส เพราะวิธีหลังจะส่งผลต่อโปรเซสลูกจำนวนมาก และในสถาปัตยกรรมหลายโปรเซสอย่าง Electron อาจทำให้การเริ่มต้นทำงานล้มเหลวได้ง่าย

ชั้นผู้ให้บริการ: รวมหลายโมเดลไว้ในอินเทอร์เฟซเดียว

ผู้ใช้ชอบโมเดลแตกต่างกันอย่างมาก และผลิตภัณฑ์ไม่อาจผูกติดกับผู้ให้บริการรายเดียวได้ ใต้ระบบควบคุมการทำงาน Orkas จึงวางชั้นนามธรรมของผู้ให้บริการไว้ เพื่อรวมโมเดลจากผู้ให้บริการต่าง ๆ ไว้หลังอินเทอร์เฟซเดียว:

interface LLMProvider {
  readonly id: string;
  complete(params: CompletionParams): Promise<CompletionResult>;
  stream(params: CompletionParams): AsyncIterable<StreamEvent>;
  validateAuth(): Promise<boolean>;
}

ตัวรันด้านบนสื่อสารกับอินเทอร์เฟซนี้เท่านั้น โดยไม่รู้ว่าผู้ให้บริการเบื้องหลังเป็นใคร รีจิสทรีจัดการกำหนดเส้นทางจากสตริงโมเดล: หากระบุในรูปแบบ provider/model อย่างชัดเจน ก็แยกได้โดยตรง หากมีเพียงชื่อโมเดล ก็ระบุผู้ให้บริการจากคำนำหน้า การยืนยันตัวตนด้วยคีย์ API หรือโทเคน OAuth ก็จัดการที่นี่เช่นกัน และโทเคน OAuth ที่หมดอายุจะได้รับการรีเฟรชโดยอัตโนมัติ

ในการรวมหลายโมเดลให้ใช้งานเหมือนกัน เรื่องปวดหัวจริง ๆ ไม่ใช่การสร้างข้อความ แต่เป็นรายละเอียดที่ความหมายในการทำงานของแต่ละผู้ให้บริการไม่ตรงกัน มีสองตัวอย่างที่เราเคยเจอปัญหา

เรื่องหนึ่งคือ การรักษาบล็อกการคิดเมื่อสลับผู้ให้บริการ โมเดลที่ใช้เหตุผลจะสร้างเนื้อหา “การคิด” ช่วงหนึ่ง ผู้ให้บริการบางรายเข้ารหัสไว้และกำหนดให้ส่งกลับแบบเดิมทุกตัวอักษร ขณะที่รายอื่นใช้ชุดฟิลด์ต่างกัน หากผู้ใช้สลับจากผู้ให้บริการ A ไป B กลางบทสนทนา ลายเซ็นของช่วงการคิดนั้นในประวัติจะไม่ตรงอีกต่อไป วิธีแก้คือกำกับทุกข้อความในประวัติว่า “โมเดลใดสร้างข้อความนี้” เพื่อให้ชั้นแปลงข้อมูลตัดสินใจได้ว่าจะเก็บไว้ตามเดิมหรือไม่: โมเดลเดียวกันให้เก็บไว้ ต่างโมเดลให้ลดรูปตามกฎ

อีกเรื่องคือ แคชพรอมป์ต์ ในแต่ละรอบของเซสชันเดียวกัน ส่วนต้นจะซ้ำกันมาก การแคชจึงช่วยลดต้นทุนและเวลารอได้อย่างมีนัยสำคัญ ระบบส่ง ID เซสชันเป็นคีย์แคชให้ผู้ให้บริการที่รองรับ พร้อมจัดการข้อจำกัดความยาวคีย์ของแต่ละราย เช่น ตัดให้สั้นหรือแฮชหากยาวเกินไป

ทั้งหมดนี้เป็นงานจุกจิก แต่ชั้นงานจุกจิกนี้เองที่ทำให้ตัวรันด้านบนทำงานเสมือนว่า “มีโมเดลอยู่เพียงชนิดเดียว” ได้

ความจำ: สองกลไก ต่างกลไกต่างหน้าที่

“ความจำ” ใน Orkas ที่จริงเป็นสองกลไกคู่ขนานที่แก้ปัญหาต่างกันโดยสิ้นเชิง กลไกหนึ่งคือฐานความรู้ที่ใช้การค้นคืน สำหรับข้อมูลจำนวนมากที่ “ค่อยไปค้นเมื่อต้องการ” อีกกลไกคือความจำข้ามเซสชัน สำหรับข้อเท็จจริงสำคัญจำนวนเล็กน้อยที่ “ควรนึกถึงอยู่เสมอ” หลายผลิตภัณฑ์รวมสองสิ่งนี้เข้าด้วยกัน การแยกกันทำให้ทุกอย่างชัดเจนกว่ามาก

ฐานความรู้: การค้นคืนแบบผสม

กลไกแรกมุ่งที่เนื้อหาขนาดใหญ่แต่เกี่ยวข้องเป็นครั้งคราว เช่น เอกสารของผู้ใช้ บันทึกเก่า และความรู้เฉพาะด้าน เป็นฐานความรู้ในเครื่องที่ใช้การค้นคืนด้วยเวกเตอร์ โดยมีแบ็กเอนด์สองแบบ: รุ่นเบาที่เก็บในหน่วยความจำล้วนสำหรับการทดสอบและการใช้งานชั่วคราว กับรุ่นที่บันทึกถาวรในฐานข้อมูลภายในเครื่องสำหรับการใช้งานจริง พร้อมดัชนีค้นหาข้อความเต็มและเวกเตอร์

ข้อมูลเข้าสู่ระบบตามเส้นทางนี้:

เอกสาร → แบ่งเป็นส่วนตามขอบเขตบรรทัด (มีช่วงซ้อนทับ) → สร้างดัชนีสองแบบ
                                          ├─ ดัชนีข้อความเต็ม (คำสำคัญ ไม่มีค่าใช้จ่ายในการสร้างเวกเตอร์ฝังตัว)
                                          └─ ดัชนีเวกเตอร์ (หากกำหนดค่าโมเดลเวกเตอร์ฝังตัวไว้)

ข้อมูลถูกแบ่งตามขอบเขตบรรทัด โดยมีช่วงซ้อนทับเล็กน้อยระหว่างส่วน เพื่อไม่ให้ตัดเนื้อหาที่มีความหมายครบถ้วนกลางคัน การค้นคืนเป็นแบบ ผสม: ค้นด้วยเวกเตอร์หนึ่งรอบเพื่อหาความหมายใกล้เคียง และค้นด้วยคำสำคัญอีกหนึ่งรอบเพื่อหาข้อความตรงตัว จากนั้นรวมผลลัพธ์ทั้งสองชุดด้วย RRF (Reciprocal Rank Fusion):

score = Σ  1 / (k + rank_i)

ยิ่งผลลัพธ์อยู่ในอันดับสูงของการค้นรอบใด ก็ยิ่งมีส่วนเพิ่มคะแนนมาก เมื่อนำคะแนนจากทั้งสองรอบมารวมกัน จึงคำนึงถึงทั้งความเกี่ยวข้องเชิงความหมายและไม่พลาดข้อความที่ตรงกันทุกตัวอักษร น้ำหนักของเวกเตอร์และคำสำคัญปรับได้ โดยค่าเริ่มต้นให้น้ำหนักความหมายมากกว่า หลังรวมผลลัพธ์ จะตัดรายการซ้ำตาม “(เอกสาร, บรรทัดเริ่มต้น)” โดยเก็บเฉพาะรายการที่ดีที่สุดของแต่ละตำแหน่ง จากนั้นตัดรายการที่ต่ำกว่าเกณฑ์ แล้วคืนผลลัพธ์ K อันดับแรก

ทำไมไม่ใช้เวกเตอร์อย่างเดียว? เพราะการค้นคืนด้วยเวกเตอร์มักพลาดกับชื่อเฉพาะ สัญลักษณ์ในโค้ด และสตริงที่ต้องตรงตัว ซึ่งเป็นคำค้นที่ไม่ได้มีความหมายพิเศษ แต่การตรงตัวมีความสำคัญมาก ขณะที่การใช้คำสำคัญอย่างเดียวก็จับ “ความหมายเดียวกันแต่ใช้คำต่างกัน” ไม่ได้ การใช้ทั้งสองแบบจึงเป็นทางเลือกที่สมดุลระหว่างคุณภาพการค้นคืนกับต้นทุนในทางปฏิบัติ

ความจำข้ามเซสชัน: จดจำผู้ใช้อยู่เสมอ

ฐานความรู้แก้ปัญหา “ข้อมูลมากเกินกว่าจะเก็บไว้ทั้งหมด” แต่ยังมีข้อมูลอีกประเภทหนึ่งที่มีปริมาณน้อยมาก แต่ต้องนึกถึงอยู่ตลอด: ผู้ใช้คนนี้เป็นใคร ชอบอะไร และครั้งก่อนตกลงอะไรกันไว้ สิ่งเหล่านี้ไม่ควรพึ่งการค้นคืนเพื่อ “โชคดีแล้วนึกออก” แต่ควรมีอยู่ในทุกรอบ

ด้วยเหตุนี้ Orkas จึงสร้างชั้นความจำข้ามเซสชันแยกต่างหาก โดยแบ่งตามเนื้อหาเป็นสองส่วน:

  • โปรไฟล์ผู้ใช้: ข้อเท็จจริงที่คงที่เกี่ยวกับ บุคคล — บทบาท ความชอบ รูปแบบการสื่อสาร และชุดเทคโนโลยีที่ใช้
  • บันทึกข้อเท็จจริง: ข้อเท็จจริงที่ใช้ได้ในระยะยาวเกี่ยวกับ งาน — การตัดสินใจ เหตุการณ์สำคัญ และข้อตกลงของโครงการ

ทั้งสองส่วนมีขนาดเล็ก แต่ละส่วนจำกัดไว้ไม่กี่พันอักขระอย่างเคร่งครัด จึงบังคับให้เก็บเฉพาะสิ่งที่มีประโยชน์ในระยะยาวจริง ๆ ข้อมูลเหล่านี้ไม่ผ่านการค้นคืน แต่ถูกใส่เป็นเนื้อหาคงที่ลงในพรอมป์ต์ระบบโดยตรงตอนเริ่มแต่ละรอบ หมายความว่าเอเจนต์ “รู้” สิ่งเหล่านี้อยู่แล้ว โดยไม่ต้องจำว่าต้องไปค้น เป็นแนวทางตรงข้ามกับฐานความรู้โดยสิ้นเชิง: ฐานความรู้คือ “ดึงมาเมื่อจำเป็น ใช้แล้วก็หายไป” ส่วนความจำข้ามเซสชันคือ “อยู่เสมอ มองเห็นเสมอ”

การเขียนข้อมูลทำผ่านเครื่องมือความจำโดยเฉพาะ ซึ่งโมเดลจะเรียกเมื่อประเมินระหว่างบทสนทนาว่า “เรื่องนี้ควรจำไว้ระยะยาว” รองรับการเพิ่ม แทนที่ข้อความย่อย และลบ คำอธิบายเครื่องมือระบุชัดเจนว่าควรและไม่ควรบันทึกอะไร: การแก้ไขข้อมูลและความชอบของผู้ใช้มีความสำคัญสูงสุด การตัดสินใจและข้อตกลงที่ใช้ระยะยาวควรถูกบันทึก ส่วนสถานะชั่วคราวของงานปัจจุบัน ข้อมูลดีบักเฉพาะครั้ง และสิ่งที่ค้นพบใหม่ได้ง่ายไม่ควรบันทึก ความจำมีไว้สำหรับ “ข้อเท็จจริงระยะยาวเกี่ยวกับผู้ใช้และโครงการ” ไม่ใช่ “ครั้งนี้ทำถึงไหนแล้ว”

มีรายละเอียดที่มองข้ามได้ง่ายแต่สำคัญมาก: ระบบจะสแกนความปลอดภัยก่อนเขียนทุกครั้ง เนื้อหานี้เข้าสู่พรอมป์ต์ระบบแบบตรงตัวและคงอยู่ข้ามเซสชันเป็นเวลานาน จึงเป็นช่องทางแทรกคำสั่งที่อยู่ได้นานโดยปริยาย ดังนั้นความจำทุกชิ้นที่จะเขียนลงดิสก์จะถูกสแกนหารูปแบบน่าสงสัยก่อน เช่น ถ้อยคำแทรกคำสั่งในพรอมป์ต์ที่พบบ่อย (“ละเว้นคำสั่งก่อนหน้านี้ทั้งหมด” และทำนองเดียวกัน) คำสั่งที่พยายามลักลอบส่งคีย์ออกไป และอักขระ Unicode ที่มองไม่เห็นซ่อนอยู่ในข้อความ หากพบจะปฏิเสธทันที เมื่อเสริมด้วยการตัดข้อมูลซ้ำและตัดส่วนที่เกินขีดจำกัด ชั้นความจำนี้จึงยังมีประโยชน์โดยไม่กลายเป็นภาระความเสี่ยง

สองกลไกนี้ร่วมกันครอบคลุมทั้งสองด้าน คือ “มหาศาลแต่ใช้เป็นครั้งคราว” และ “เล็กน้อยแต่ใช้ตลอด” ฐานความรู้ดูแลอย่างแรก ส่วนความจำข้ามเซสชันดูแลอย่างหลัง เมื่อเพิ่มความเข้าใจของเอเจนต์เกี่ยวกับ ตัวเอง (หัวข้อของบทความถัดไป) เอเจนต์ Orkas ก็เข้ามาพร้อมความจำสามประเภทในคราวเดียว ได้แก่ ความจำเกี่ยวกับข้อมูล เกี่ยวกับผู้ใช้ และเกี่ยวกับตัวเอง

เซสชัน: ออกแบบให้รับมือการล่มและฟื้นตัวได้

เซสชันจัดการประวัติข้อความ รุ่นพื้นฐานเป็นเพียงอาร์เรย์ข้อความในหน่วยความจำที่รองรับการตัดและย่อประวัติ แต่สิ่งใดก็ตามที่ทำงานบนเครื่องผู้ใช้ต้องถือว่าอาจถูกยุติได้ทุกเมื่อ ไม่ว่าจะเป็นผู้ใช้ปิดแอป ระบบรีบูต หรือกลไกเฝ้าระวังหมดเวลาแล้วหยุดโปรเซส การใช้งานจริงจึงใช้เซสชันแบบถาวร โดยเขียนลงไฟล์ JSONL ในเครื่อง หนึ่งข้อความต่อหนึ่งบรรทัด

มีกลยุทธ์การเขียนสองแบบ: การเพิ่มข้อความใหม่ใช้การเขียนต่อท้ายแบบอะตอมมิก ส่วนสิ่งที่เขียนทั้งไฟล์ใหม่ เช่น การย่อบริบทหรือการล้างข้อมูล ใช้วิธี “เขียนไฟล์ชั่วคราว + เปลี่ยนชื่อแบบอะตอมมิก” ด้วยวิธีนี้ แม้ไฟดับระหว่างเขียน ก็จะไม่ทิ้งระเบียนที่เสียหายค้างไว้ครึ่งเดียว

ส่วนที่น่าสนใจที่สุดคือ การซ่อมแซมการเรียกเครื่องมือที่ไร้คู่ กลับมาที่ข้อกำหนดการจับคู่: โมเดลเรียกเครื่องมือ ระบบควบคุมสั่งให้ทำงาน แล้วเขียนผลลัพธ์กลับ หากขั้นตอนใดในสามขั้นตอนนี้ถูกขัดจังหวะ ก็จะทิ้งข้อมูลไร้คู่บนดิสก์เป็น “การเรียกที่ไม่มีผลลัพธ์” หากครั้งถัดไปโหลดเซสชันนั้นแล้วส่งให้โมเดลตามเดิม API จะปฏิเสธหรือค้าง

ตรรกะซ่อมแซมทำงานทุกครั้งที่โหลดเซสชันจากดิสก์ และทำซ้ำได้โดยให้ผลเดิม:

  1. สแกนข้อความของผู้ช่วยทั้งหมด แล้วรวบรวม ID การเรียกเครื่องมือที่เกิดขึ้น
  2. ค้นหาผลลัพธ์เครื่องมือที่ตรงกันในข้อความถัดไป
  3. สำหรับการเรียกใดที่ไม่มีผลลัพธ์ตรงกัน ให้สร้างผลลัพธ์ขึ้นหนึ่งรายการโดยระบุว่า “ถูกขัดจังหวะ”
  4. ระหว่างนั้น จัดลำดับผลลัพธ์ให้ตรงกับลำดับการประกาศเรียก และทิ้งผลลัพธ์ไร้คู่ที่ไม่มีการเรียกตรงกัน

หลังผ่านขั้นตอนนี้ รับประกันได้ว่าเซสชันอยู่ในสถานะที่ตรงตามข้อกำหนดการจับคู่ของ API และส่งได้อย่างปลอดภัย กลไกนี้ดูธรรมดา แต่เป็นตาข่ายนิรภัยที่ช่วยให้ “บทสนทนาของผู้ใช้ไม่ค้างถาวรเพียงเพราะระบบล่มครั้งเดียว”

การตัดสินใจบางข้อที่เห็นคุณค่าเมื่อมองย้อนกลับ

เมื่อนำทั้งหมดมารวมกัน การตัดสินใจบางข้อดูมีคุณค่าเป็นพิเศษเมื่อมองย้อนหลัง

ใช้เจเนอเรเตอร์เป็นอินเทอร์เฟซหลัก แบบสตรีมและไม่สตรีมใช้การทำงานชุดเดียวกัน สถานะระหว่างทางแสดงออกมาได้อย่างเป็นธรรมชาติ และ UI จะแสดงรายละเอียดมากเท่าใดก็ได้ตามต้องการ วิธีนี้ช่วยหลีกเลี่ยงบั๊กความไม่สอดคล้องกันทั้งกลุ่มที่อาจเกิดจากการ “ทำแบบไม่สตรีมก่อน แล้วค่อยต่อสตรีมเพิ่มทีหลัง”

ย่อบริบทเมื่อถึง 60% ไม่ใช่รอจนเต็ม วิธีนี้เหลือพื้นที่เผื่อสำหรับการย่อบริบทเอง ซึ่งต้องเรียกโมเดลเช่นกัน และไม่ต้องเร่งแก้ปัญหาในวินาทีสุดท้าย

ข้อกำหนดการจับคู่ครอบคลุมทุกส่วน ตั้งแต่จุดตัดตอนย่อบริบท การเขียนลงดิสก์ ไปจนถึงการซ่อมแซมขณะโหลด ทุกจุดที่แตะเซสชันยึดกฎเดียวกัน เมื่อมีกฎเดียว แต่ละจุดก็ไม่ต้องคิดตรรกะแก้ปัญหาของตัวเอง

รวมงานจุกจิกไว้ในชั้นผู้ให้บริการ ความยุ่งยากระหว่างผู้ให้บริการทั้งหมด ไม่ว่าจะเป็นบล็อกการคิด คีย์แคช หรือความสามารถที่ต่างกัน จะถูกจัดการในชั้นเดียวนี้ เพื่อให้ตัวรันด้านบนเรียบง่าย หากวันหนึ่งเพิ่มผู้ให้บริการโมเดลรายใหม่ การเปลี่ยนแปลงก็แทบไม่ลามออกไป

ส่งท้าย

ระบบควบคุมการทำงานของ Orkas ไม่มีอัลกอริทึมหวือหวา คุณค่าของมันอยู่ที่การนำโจทย์ “ทำให้เอเจนต์ทำงานได้อย่างน่าเชื่อถือในสภาพแวดล้อมจริง” มาแยกเป็นชุดโมดูลที่มีขอบเขตชัดเจน แต่ละโมดูลรับผิดชอบส่วนของตัวเอง: ตัวรันดูแลวงจรและการลองใหม่ เครื่องมือดูแลความสามารถ ชั้นผู้ให้บริการดูแลการรวมหลายโมเดล ความจำดูแลการค้นคืน และเซสชันดูแลการเก็บถาวรกับการซ่อมแซม ไม่มีส่วนใดซับซ้อนด้วยตัวเอง แต่เมื่อนำมารวมกันจึงรองรับสิ่งที่ผู้คนใช้งานได้ทุกวัน

หากมีสิ่งที่ควรนำไปใช้ ก็คือ: ทำวงจรการทำงานเป็นเจเนอเรเตอร์แบบสตรีม แล้วการจัดการสถานะระหว่างทางจะง่ายขึ้นมาก เมื่อกำหนดกฎหลักแล้ว เช่น “การเรียกเครื่องมือต้องมีผลลัพธ์คู่กัน” ให้ยึดกฎนั้นอย่างสม่ำเสมอทั้งตอนย่อบริบท เขียนดิสก์ และโหลด อย่าให้ส่วนใดเป็นข้อยกเว้น รวมงานจุกจิกข้ามผู้ให้บริการไว้ในชั้นเดียวและแยกออกจากตรรกะธุรกิจ และข้อที่ตรงไปตรงมาที่สุดคือ ให้ถือว่าโปรเซสจะถูกยุติในจังหวะที่แย่ที่สุด แล้วเขียนกลไกซ่อมแซมสำหรับจังหวะนั้นไว้ล่วงหน้า

บทความถัดไปจะเจาะส่วนที่น่าสนใจยิ่งขึ้นของ Orkas: เอเจนต์นี้เรียนรู้จากการใช้งานของตัวเอง กลั่นประสบการณ์เป็นสกิลที่ใช้ซ้ำได้ และค่อย ๆ ทำให้ตัวเองมีประโยชน์มากขึ้นได้อย่างไร