DEV Community

Cover image for ผมเบื่อที่จะเขียนโค้ด AI Agent ซ้ำๆ - นี่คือวิธีที่ผมแก้ปัญหา (ด้วย Boilerplate)
Shanmukh Ram
Shanmukh Ram

Posted on

ผมเบื่อที่จะเขียนโค้ด AI Agent ซ้ำๆ - นี่คือวิธีที่ผมแก้ปัญหา (ด้วย Boilerplate)

ผมเบื่อที่จะเขียน AI Agent Code ซ้ำๆ ทุกโปรเจกต์ — เลยสร้าง Boilerplate ที่ใช้งานได้จริง

ทุกครั้งที่เริ่มโปรเจกต์ AI Agent ใหม่ ผมต้องเขียน code เดิมซ้ำๆ

Tool use loop. Memory persistence. Retry logic. Structured logging.

ซ้ำแล้วซ้ำเล่า. Copy-paste จากโปรเจกต์เก่า. แก้ชื่อตัวแปร. หวังว่าจะไม่เกิด bug ใหม่.

คุณเคยเป็นแบบนี้ไหม?

ผมเลยหยุดทำแบบนั้น — และแพ็กทุกอย่างลงใน TypeScript boilerplate ที่สะอาดและใช้งานได้จริง ในบทความนี้ผมจะอธิบายว่ามันทำงานอย่างไร เพื่อให้คุณสามารถนำไปใช้หรือสร้างเองได้เลย


ปัญหาของ Tutorial AI Agent ทั่วไป

Tutorial ส่วนใหญ่สอนแค่นี้:

const response = await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Hello!" }],
});
console.log(response.content);
Enter fullscreen mode Exit fullscreen mode

โอเคสำหรับ demo แต่พอจะทำอะไรจริงๆ คุณจะเจอปัญหา:

  • Tool calls คืออะไร? Claude ไม่ได้ return แค่ text — มันส่ง tool_use blocks ที่คุณต้องรัน และส่งผลกลับไป
  • Multi-turn ทำยังไง? Agent จริงต้อง loop จนกว่างานจะเสร็จ ไม่ใช่แค่ request/response เดียว
  • API ล้มเหลวทำยังไง? Rate limits, timeout, network error — Tutorial ไม่มีใครสอนเรื่องนี้
  • Memory จะเก็บยังไง? Agent ลืมทุกอย่างทันทีที่ process restart

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


Core Agent Loop

นี่คือหัวใจของทุกอย่าง Tutorial ส่วนใหญ่ทำผิดตรงนี้:

async function runAgent(task: string, config: AgentConfig): Promise<void> {
  const messages: MessageParam[] = [
    { role: "user", content: task }
  ];

  for (let turn = 0; turn < config.maxTurns; turn++) {
    const response = await retryWithBackoff(() =>
      anthropic.messages.create({
        model: config.model,
        max_tokens: config.maxTokens,
        tools: getToolDefinitions(config.tools),
        messages,
      })
    );

    logger.info(`Turn ${turn + 1}`, {
      stopReason: response.stop_reason,
      inputTokens: response.usage.input_tokens,
      outputTokens: response.usage.output_tokens,
    });

    const toolResults: ToolResultBlockParam[] = [];

    for (const block of response.content) {
      if (block.type === "text") {
        logger.info("Agent:", { text: block.text });
      } else if (block.type === "tool_use") {
        logger.info("Tool call:", { name: block.name, input: block.input });
        const result = await executeToolCall(block, config.tools);
        logger.info("Tool result:", { name: block.name, result });
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: result,
        });
      }
    }

    messages.push({ role: "assistant", content: response.content });

    if (toolResults.length > 0) {
      messages.push({ role: "user", content: toolResults });
    }

    if (response.stop_reason === "end_turn" && toolResults.length === 0) {
      logger.info("Agent ทำงานเสร็จแล้ว");
      return;
    }
  }

  logger.warn("ถึง max turns แล้วแต่ยังไม่เสร็จ");
}
Enter fullscreen mode Exit fullscreen mode

สิ่งที่ handle ที่ Tutorial ข้ามไป:

  1. Tool calls หลายตัวใน turn เดียว — Claude เรียก 3 tools พร้อมกันได้ คุณต้องรันทั้งหมดและส่งผลกลับพร้อมกัน
  2. Mixed content — Response มีทั้ง text และ tool calls ต้อง loop ผ่าน response.content ทั้งหมด
  3. โครงสร้าง turn ที่ถูกต้อง — Tool results ต้องอยู่ใน user message ไม่ใช่ assistant
  4. เงื่อนไขหยุด — หยุดเมื่อ stop_reason === "end_turn" และไม่มี tool calls ค้างอยู่

Retry Logic ที่ใช้งานได้จริง

API calls ล้มเหลวเสมอ Rate limits, network blips, timeout — Agent คุณจะเจอแน่นอน:

const RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504];

export async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  baseDelayMs = 1000
): Promise<T> {
  let lastError: Error;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;

      const isRetryable =
        error instanceof Anthropic.APIError &&
        RETRYABLE_STATUS_CODES.includes(error.status);

      if (!isRetryable || attempt === maxRetries) {
        throw error;
      }

      const delay = baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.random() * 200;

      await sleep(delay + jitter);
    }
  }

  throw lastError!;
}
Enter fullscreen mode Exit fullscreen mode

Exponential backoff พร้อม jitter — retry บน 429 (rate limit), 500, 502, 503, 504 แต่ fail fast บน 400, 401, 403 เพราะนั่นคือ bug ของคุณ ไม่ใช่ error ชั่วคราว


Persistent Memory

Agent จริงต้องมี memory นี่คือ file-based store ที่เรียบง่าย:

export class MemoryStore {
  private data: Record<string, unknown> = {};
  private filePath: string;

  constructor(filePath: string) {
    this.filePath = filePath;
    this.load();
  }

  private load(): void {
    try {
      const raw = readFileSync(this.filePath, "utf-8");
      this.data = JSON.parse(raw);
    } catch {
      this.data = {};
    }
  }

  private save(): void {
    writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
  }

  set(key: string, value: unknown): void {
    this.data[key] = value;
    this.save();
  }

  get(key: string): unknown {
    return this.data[key] ?? null;
  }

  keys(): string[] {
    return Object.keys(this.data);
  }
}
Enter fullscreen mode Exit fullscreen mode

เรียบง่าย อ่านจาก disk ตอน init เขียนทุกครั้งที่มีการเปลี่ยนแปลง รอด process restart สำหรับ production คุณอาจ swap เป็น Redis หรือ Postgres แต่นี่เพียงพอสำหรับเริ่มต้นโดยไม่ต้องมี dependencies เพิ่ม


Tool Registry Pattern

การเพิ่ม tool ควรทำได้ง่ายมาก นี่คือ pattern ที่ทำให้ใช้แค่ 10 บรรทัด:

export interface ToolHandler {
  definition: Tool;
  execute: (input: Record<string, unknown>) => Promise<string>;
}

// เพิ่ม tool ใหม่แค่นี้:
export const myTool: ToolHandler = {
  definition: {
    name: "my_tool",
    description: "อธิบายให้ชัดเจน Claude จะอ่านตรงนี้",
    input_schema: {
      type: "object",
      properties: {
        input: { type: "string", description: "Input ที่รับเข้ามา" },
      },
      required: ["input"],
    },
  },
  execute: async (input) => {
    return `ผลลัพธ์: ${input["input"]}`;
  },
};
Enter fullscreen mode Exit fullscreen mode

Register ตอนสร้าง agent:

runAgent(task, {
  tools: { my_tool: myTool, ...defaultTools },
});
Enter fullscreen mode Exit fullscreen mode

แค่นั้นเอง


ทดสอบรันจริง

npm start "คำนวณดอกเบี้ยทบต้นของ 100,000 บาท ที่ 7% เป็นเวลา 10 ปี และบันทึกผลลัพธ์ลงใน memory"
Enter fullscreen mode Exit fullscreen mode

Output:

{"timestamp":"2026-02-27T04:00:00.000Z","level":"INFO","message":"Turn 1","data":{"stopReason":"tool_use","inputTokens":512,"outputTokens":128}}
{"timestamp":"2026-02-27T04:00:00.001Z","level":"INFO","message":"Tool call:","data":{"name":"calculator","input":{"expression":"100000 * (1 + 0.07)^10"}}}
{"timestamp":"2026-02-27T04:00:00.002Z","level":"INFO","message":"Tool result:","data":{"name":"calculator","result":"196715.14"}}
{"timestamp":"2026-02-27T04:00:00.100Z","level":"INFO","message":"Tool call:","data":{"name":"memory_write","input":{"key":"compound_interest","value":196715.14}}}
{"timestamp":"2026-02-27T04:00:00.101Z","level":"INFO","message":"Agent ทำงานเสร็จแล้ว"}
Enter fullscreen mode Exit fullscreen mode

ดาวน์โหลด Boilerplate เต็ม

ถ้าคุณต้องการ codebase ที่พร้อมรัน — ทุกอย่างที่เขียนไว้ข้างบน เชื่อมต่อกันหมดแล้ว TypeScript configure แล้ว มี examples ให้ครบ .env.example พร้อมใช้:

AI Agent Boilerplate บน Gumroad →

$39 (ประมาณ 1,400 บาท). MIT license. Clone ได้เลย เป็นเจ้าของ 100% ใช้ทำ production ได้เลย

หรือจะสร้างเองจากบทความนี้ก็ได้ — ทุกอย่างที่จำเป็นมีครบที่นี่แล้ว


สรุป

หยุดเขียน scaffolding เดิมซ้ำๆ แล้วไปสร้างของจริงดีกว่า

มีคำถามอะไรคอมเมนต์ไว้เลยนะครับ ยินดีช่วยเสมอ 🙏

Top comments (0)