챗봇에게 "방금 말한 그거 다시 정리해줘"라고 했더니 엉뚱한 답이 돌아온 경험, 한 번쯤 있을 겁니다. 원인은 대부분 같습니다. LLM API는 상태가 없습니다(stateless). 서버는 이전 요청을 기억하지 않으며, 매 호출은 백지에서 시작합니다. 그럼에도 우리가 쓰는 ChatGPT나 Claude 앱이 대화를 "기억"하는 것처럼 보이는 이유는, 애플리케이션이 지금까지의 대화 전체를 매번 다시 보내주기 때문입니다. 이 글에서는 맥락을 유지하는 대화형 AI의 핵심 메커니즘 — messages 배열 누적, 시스템 프롬프트, user/assistant 교대 규칙, 대화 히스토리의 DB/세션 저장과 복원, 긴 대화의 토큰 관리, 그리고 도구 호출과의 결합 — 을 Claude API를 1차 예제로, Laravel 챗봇 저장 예시를 곁들여 실무 코드 중심으로 정리합니다.

API는 stateless다 — 맥락은 messages 누적으로 만든다

핵심 사실부터 명확히 합시다. Claude의 Messages API(그리고 OpenAI Chat Completions, Gemini도 마찬가지)는 요청 간에 어떤 상태도 보존하지 않습니다. conversation_id 같은 서버측 세션 핸들이 있는 것이 아니라, 매 요청에 대화의 모든 턴을 담은 배열을 통째로 전송해야 합니다. 모델은 그 배열을 읽고 "다음에 올 assistant 발화"를 생성할 뿐입니다.

따라서 "대화를 기억한다"는 것은 결국 다음 루프로 환원됩니다. (1) 사용자 입력을 messages 끝에 user 메시지로 추가한다 → (2) 전체 배열을 API에 보낸다 → (3) 돌아온 assistant 응답을 다시 messages 끝에 추가한다 → (4) 다음 입력을 기다린다. 이 누적된 배열이 곧 "대화 상태"입니다. 가장 작은 멀티턴 예제를 보겠습니다.

from anthropic import Anthropic

client = Anthropic()  # ANTHROPIC_API_KEY 환경변수 사용

# 이 리스트가 곧 '대화 상태'다. 매 턴 끝에 누적된다.
messages = []

def chat(user_input: str) -> str:
    # 1) 사용자 입력을 user 메시지로 추가
    messages.append({"role": "user", "content": user_input})

    # 2) 지금까지의 전체 대화를 통째로 전송
    msg = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=1024,
        system="당신은 간결하게 답하는 한국어 기술 비서입니다.",
        messages=messages,
    )

    # 3) 응답 텍스트 추출 — content는 블록들의 리스트다
    reply = "".join(block.text for block in msg.content if block.type == "text")

    # 4) assistant 응답을 다시 누적 → 다음 턴의 맥락이 된다
    messages.append({"role": "assistant", "content": reply})
    return reply

print(chat("파이썬 리스트 컴프리헨션이 뭐야?"))
print(chat("방금 그거 예제 하나만 보여줘"))  # '방금 그거' = 직전 답변. 누적 덕분에 이해함

두 번째 호출에서 "방금 그거"가 통하는 이유는, 첫 턴의 user/assistant가 모두 messages에 남아 두 번째 요청에 함께 전송되기 때문입니다. 만약 매 호출마다 messages를 새로 만들었다면 모델은 "방금"이 무엇인지 알 길이 없습니다. 맥락 유지의 본질은 토큰을 다시 보내는 것이며, 그래서 대화가 길어질수록 비용과 지연이 늘어납니다(뒤에서 토큰 관리로 다룹니다).

system 프롬프트로 페르소나와 규칙을 고정한다

Claude에서 시스템 프롬프트는 messages 안에 넣는 것이 아니라 별도의 system 파라미터로 전달합니다. 이것은 OpenAI가 messages 배열의 첫 항목으로 {"role": "system", ...}을 넣는 방식과 다른 점이니 주의하세요. 시스템 프롬프트는 모델의 페르소나, 말투, 지켜야 할 규칙, 도메인 지식 등 대화 내내 고정되어야 할 지침을 담습니다.

SYSTEM = """당신은 'DevBot'이라는 이름의 시니어 백엔드 엔지니어입니다.
- 한국어로, 존댓말로 답합니다.
- 추측하지 않고, 모르면 모른다고 말합니다.
- 코드 예제는 실행 가능한 완전한 형태로 제공합니다.
- 답변은 핵심부터, 군더더기 없이."""

msg = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system=SYSTEM,               # 별도 파라미터
    messages=[
        {"role": "user", "content": "DB 커넥션 풀 크기는 어떻게 정하나요?"},
    ],
)

규칙은 가급적 시스템 프롬프트에 한 번만 명시하고, 매 user 메시지마다 반복하지 마세요. 반복은 토큰만 늘리고 캐시를 깨뜨립니다. 또한 시스템 프롬프트는 대화 도중에 바꾸지 않는 것이 원칙입니다. 중간에 system 텍스트를 수정하면 프롬프트 캐시의 접두(prefix)가 달라져 누적된 대화 전체가 캐시 미스로 재처리됩니다(캐싱은 마지막 섹션에서).

messages 규칙: user로 시작하고, user/assistant가 교대해야 한다

Messages API에는 어기면 400 에러가 나는 구조 규칙이 있습니다.

  • 첫 메시지는 반드시 user여야 합니다. assistant로 시작하면 거부됩니다.
  • 역할이 교대(alternate)해야 합니다. user 두 개가 연속되거나 assistant 두 개가 연속되면 "roles must alternate" 에러가 납니다.
  • 같은 역할로 여러 내용을 보내야 한다면, 별개의 메시지로 나누지 말고 하나의 메시지 안에서 content 블록을 여러 개로 구성하세요.

실무에서 이 규칙을 깨는 가장 흔한 경우는, 사용자가 봇 답변을 기다리지 않고 연달아 입력을 보낼 때입니다. 이때는 두 개의 user 메시지를 만들지 말고, 큐에 모아 하나의 user 메시지로 합치거나 직전 메시지에 이어 붙이세요. 참고로 Claude는 임의의 빈 문자열 content를 허용하지 않으므로, content가 비지 않도록 보장해야 합니다.

대화 히스토리를 DB/세션에 저장하고 복원한다

위 예제들은 messages를 메모리(파이썬 리스트)에 들고 있었습니다. 하지만 웹 챗봇은 요청-응답이 끝나면 프로세스 상태가 사라지고, 사용자는 며칠 뒤에 같은 대화를 이어가길 원합니다. 따라서 대화 히스토리를 영속 저장소(DB)나 세션에 직렬화해 저장하고, 다음 요청에서 복원해야 합니다. 패턴은 단순합니다. 한 대화(conversation)당 메시지들을 행으로 저장하고, API 호출 직전에 그 대화의 메시지를 시간순으로 읽어 messages 배열을 재구성합니다.

Laravel로 챗봇 대화 저장을 구현해 봅니다. 먼저 마이그레이션입니다.

// database/migrations/xxxx_create_chat_tables.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('conversations', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title')->nullable();
            $table->timestamps();
        });

        Schema::create('chat_messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
            $table->enum('role', ['user', 'assistant']);  // system은 별도 보관
            $table->longText('content');
            $table->unsignedInteger('tokens')->nullable(); // 사용량 추적용(선택)
            $table->timestamps();
        });
    }
};

이제 Laravel용 Anthropic SDK로 컨트롤러를 작성합니다. composer require anthropic-ai/sdk로 설치합니다. PHP SDK는 응답의 도구/사용량 필드를 camelCase로 노출한다는 점(inputSchema, stopReason 등)을 기억하세요.

// app/Http/Controllers/ChatController.php
namespace App\Http\Controllers;

use App\Models\Conversation;
use App\Models\ChatMessage;
use Anthropic\Client;
use Illuminate\Http\Request;

class ChatController extends Controller
{
    private const SYSTEM = '당신은 한국어로 답하는 친절한 기술 지원 봇입니다. 모르면 모른다고 답합니다.';

    public function send(Request $request, Conversation $conversation)
    {
        $request->validate(['message' => 'required|string|max:8000']);

        // 1) 사용자 메시지를 먼저 DB에 저장
        ChatMessage::create([
            'conversation_id' => $conversation->id,
            'role'    => 'user',
            'content' => $request->input('message'),
        ]);

        // 2) 이 대화의 전체 히스토리를 시간순으로 복원 → API 형식으로 매핑
        $messages = $conversation->messages()
            ->orderBy('id')
            ->get()
            ->map(fn (ChatMessage $m) => [
                'role'    => $m->role,
                'content' => $m->content,
            ])
            ->all();

        // 3) 전체 대화를 통째로 전송 (system은 별도 파라미터)
        $client = new Client(apiKey: getenv('ANTHROPIC_API_KEY'));
        $response = $client->messages->create(
            model: 'claude-opus-4-8',
            maxTokens: 1024,
            system: self::SYSTEM,
            messages: $messages,
        );

        // 4) 응답 텍스트 추출 — content는 블록 배열, text 블록만 모음
        $reply = '';
        foreach ($response->content as $block) {
            if ($block->type === 'text') {
                $reply .= $block->text;
            }
        }

        // 5) assistant 응답을 다시 DB에 저장 → 다음 턴의 맥락이 됨
        ChatMessage::create([
            'conversation_id' => $conversation->id,
            'role'    => 'assistant',
            'content' => $reply,
        ]);

        return response()->json(['reply' => $reply]);
    }
}

이 구조의 장점은 명확합니다. 대화 상태가 DB에 있으므로 서버를 재시작하거나 다른 워커가 요청을 받아도 맥락이 끊기지 않습니다. 시스템 프롬프트는 코드 상수나 conversations 테이블의 컬럼으로 관리하고, user/assistant 턴만 chat_messages에 적재합니다. 소규모 단기 대화라면 DB 대신 Laravel 세션(session()->put('messages', ...))에 배열째 넣어도 되지만, 세션은 휘발성이고 용량 제한이 있으므로 영속성이 필요하면 DB를 권장합니다.

한 가지 흔한 함정: DB에서 메시지를 읽어올 때 user/assistant 교대가 깨지지 않도록 주의하세요. 예를 들어 이전 요청에서 API 호출이 실패해 assistant 응답이 저장되지 않았다면, 다음 복원 시 user가 연속될 수 있습니다. 저장은 "사용자 메시지 저장 → API 성공 → assistant 저장"을 한 트랜잭션처럼 다루거나, 복원 시점에 연속된 동일 역할을 병합하는 방어 로직을 두는 편이 안전합니다.

긴 대화의 토큰 관리: 요약과 컴팩션

맥락을 누적한다는 것은 곧 매 요청의 입력 토큰이 단조 증가한다는 뜻입니다. Claude Opus 4.8은 컨텍스트 윈도가 1M 토큰으로 매우 크지만, 무한은 아니고 입력 토큰이 늘면 비용($5/1M 입력)과 지연도 함께 증가합니다. 수백 턴짜리 고객 상담 봇이나 장시간 에이전트라면 대화 길이를 관리하는 전략이 필요합니다. 실무에서 쓰는 세 가지 접근을 정리합니다.

  • 슬라이딩 윈도(잘라내기): 가장 단순합니다. 최근 N턴만 남기고 오래된 메시지를 버립니다. 구현이 쉽지만 초반 맥락(사용자 이름, 초기 요구사항 등)이 통째로 사라지는 단점이 있습니다.
  • 요약(summarization): 오래된 메시지 구간을 Claude로 한 문단으로 요약해, 원본 다수 턴을 짧은 요약 한 덩어리로 치환합니다. 맥락의 핵심을 보존하면서 토큰을 크게 줄입니다. 직접 구현 시 가장 널리 쓰이는 방법입니다.
  • 컴팩션(compaction): Claude가 제공하는 서버측 자동 요약 기능입니다. 대화가 임계치(기본 약 150K 토큰)에 다가가면 API가 이전 맥락을 컴팩션 블록으로 자동 압축합니다. Opus 4.8/4.7/4.6, Sonnet 4.6 등에서 베타로 제공되며 베타 헤더 compact-2026-01-12가 필요합니다.

먼저 토큰을 정확히 재는 법부터. 추정치(tiktoken 등)는 Claude에 부정확하므로, 반드시 Claude의 토큰 카운팅 엔드포인트를 쓰세요.

# 현재 대화가 몇 토큰인지 정확히 측정 (tiktoken 금지 — 부정확)
count = client.messages.count_tokens(
    model="claude-opus-4-8",
    system=SYSTEM,
    messages=messages,
).input_tokens
print(f"현재 입력 토큰: {count}")

직접 요약을 구현하는 패턴은 다음과 같습니다. 임계치를 넘으면 앞쪽 메시지들을 별도 호출로 요약하고, 그 요약을 새 대화의 첫머리에 끼워 넣습니다.

def summarize_old_turns(old_messages: list) -> str:
    """오래된 대화 구간을 한 문단 요약으로 압축한다."""
    transcript = "\n".join(
        f"{m['role']}: {m['content']}" for m in old_messages
        if isinstance(m["content"], str)
    )
    resp = client.messages.create(
        model="claude-haiku-4-5",  # 요약은 저렴한 모델로 충분
        max_tokens=512,
        system="다음 대화를 사실 위주로 한 문단으로 요약하세요. 사용자의 목표와 결정 사항을 보존하세요.",
        messages=[{"role": "user", "content": transcript}],
    )
    return "".join(b.text for b in resp.content if b.type == "text")

def compact_if_needed(messages: list, threshold: int = 100_000) -> list:
    tokens = client.messages.count_tokens(
        model="claude-opus-4-8", system=SYSTEM, messages=messages,
    ).input_tokens
    if tokens < threshold:
        return messages

    # 최근 6턴은 원문 유지, 그 이전은 요약으로 치환
    recent = messages[-6:]
    old = messages[:-6]
    summary = summarize_old_turns(old)

    # 요약을 user 턴으로 주입하고, 교대 규칙을 지키기 위해 첫 메시지는 user여야 함
    return [
        {"role": "user", "content": f"[이전 대화 요약]\n{summary}"},
        {"role": "assistant", "content": "네, 이전 맥락을 파악했습니다. 이어서 도와드리겠습니다."},
        *recent,
    ]

요약 방식의 한 가지 주의점은 교대 규칙입니다. 위처럼 요약을 user로 주입했다면 그다음은 assistant로 받아 교대를 맞춘 뒤, 잘라낸 최근 턴들을 이어 붙여야 합니다. 그리고 요약은 비용 절감이 목적이므로 요약 자체는 Haiku 같은 저렴·고속 모델로 처리하는 것이 합리적입니다.

반면 Claude의 서버측 컴팩션을 쓰면 이 로직을 직접 짤 필요가 없습니다. 다만 치명적인 사용 규칙이 하나 있습니다. 컴팩션을 켰을 때는 매 턴 응답의 텍스트만 뽑아 누적하면 안 되고, response.content(블록 리스트) 전체를 그대로 messages에 다시 넣어야 합니다. 컴팩션 블록이 응답에 포함되는데, 텍스트만 추출해 누적하면 컴팩션 상태가 조용히 유실됩니다.

멀티턴과 도구 호출(function calling)을 결합한다

실전 챗봇은 단순 문답을 넘어 외부 데이터를 조회하거나 액션을 수행해야 합니다. 이때 도구 호출(function calling)이 등장하고, 이것 역시 멀티턴 누적 규칙 위에서 동작합니다. Claude의 도구 호출 흐름은 이렇습니다.

  1. tools 배열로 도구의 이름·설명·input_schema(JSON Schema)를 정의해 요청합니다.
  2. 모델이 도구가 필요하다고 판단하면, 응답의 stop_reason"tool_use"가 되고 content에 tool_use 블록(.name, 이미 파싱된 dict인 .input, .id)이 담깁니다.
  3. 여러분의 코드가 실제 함수를 실행하고, 결과를 tool_result 블록으로 만들어 다시 보냅니다. 이때 assistant 턴(모델의 tool_use 포함)과 user 턴(tool_result)을 messages에 누적해야 합니다.
  4. 모델이 도구 결과를 보고 최종 답변을 생성합니다(stop_reason == "end_turn").

핵심은 도구 호출도 결국 대화에 두 턴이 더 쌓이는 것이라는 점입니다 — 모델의 도구 요청(assistant)과 그 결과(user). 이 누적을 빠뜨리면 모델은 자기가 무슨 도구를 왜 불렀는지 잊습니다. 수동 루프 예제를 봅니다.

import json
from anthropic import Anthropic

client = Anthropic()

tools = [
    {
        "name": "get_order_status",
        "description": "주문 번호로 배송 상태를 조회한다. 사용자가 주문/배송을 물을 때 호출하세요.",
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string", "description": "주문 번호, 예: A12345"},
            },
            "required": ["order_id"],
        },
    }
]

def get_order_status(order_id: str) -> dict:
    # 실제로는 DB/외부 API 조회. 여기서는 예시 데이터
    return {"order_id": order_id, "status": "배송중", "eta": "2026-06-21"}

messages = [{"role": "user", "content": "A12345 주문 지금 어디쯤이야?"}]

while True:
    resp = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=1024,
        system="당신은 쇼핑몰 고객지원 봇입니다.",
        tools=tools,
        messages=messages,
    )

    if resp.stop_reason != "tool_use":
        # 최종 답변
        print("".join(b.text for b in resp.content if b.type == "text"))
        break

    # 1) 모델의 도구 요청(assistant 턴)을 그대로 누적
    messages.append({"role": "assistant", "content": resp.content})

    # 2) 각 tool_use 블록을 실행하고 tool_result를 모음
    tool_results = []
    for block in resp.content:
        if block.type == "tool_use":
            if block.name == "get_order_status":
                # block.input은 이미 파싱된 dict — raw 문자열 매칭 금지
                result = get_order_status(block.input["order_id"])
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(result, ensure_ascii=False),
                })

    # 3) tool_result를 user 턴으로 누적 → 루프가 다시 모델을 호출
    messages.append({"role": "user", "content": tool_results})

위 루프에서 messages가 어떻게 자라는지 추적해 보면 이해가 쉽습니다. 초기 user → 모델의 tool_use(assistant) → tool_result(user) → 모델의 최종 텍스트(end_turn). 도구를 여러 번 연쇄로 호출하면 이 패턴이 반복되며 턴이 더 쌓입니다. 도구 정의에는 "언제 호출해야 하는지"를 설명에 명시하는 것이 should-call 정확도를 높입니다. 또한 tool_choice로 호출 정책을 제어할 수 있습니다: {"type": "auto"}(기본, 모델이 판단) / {"type": "any"}(아무 도구나 반드시) / {"type": "tool", "name": "get_order_status"}(특정 도구 강제) / {"type": "none"}(도구 금지).

직접 루프를 짜기 번거롭다면, Python/TypeScript/PHP SDK가 제공하는 도구 러너(tool runner, 베타)가 이 루프 — API 호출, 도구 실행, 결과 회신, 반복 — 를 자동으로 돌려줍니다. 다만 인간 승인(human-in-the-loop)이나 커스텀 로깅이 필요하면 위처럼 수동 루프가 더 적합합니다.

비교: OpenAI와 Gemini의 멀티턴 패턴

맥락 누적이라는 큰 그림은 공급사가 달라도 동일합니다. API는 stateless이고, 히스토리를 매번 보냅니다. 차이는 시스템 프롬프트의 위치와 필드 이름 정도입니다. 아래 표로 핵심만 비교합니다(모델 ID는 안정적이지만, 최신 제공 모델은 각 공급사 현행 문서 확인을 권장합니다).

항목Claude (Anthropic)OpenAIGoogle Gemini
시스템 프롬프트별도 system 파라미터messages[0]role: "system"system_instruction 설정
대화 누적messages 배열(user/assistant 교대)messages 배열contents 배열(role: user/model)
도구 결과 회신tool_result 블록(user 턴)role: "tool" 메시지function_response part
도구 정의 키input_schemafunction.parametersfunction_declarations

OpenAI에서는 시스템 메시지를 배열 맨 앞에 넣고 누적합니다.

from openai import OpenAI

client = OpenAI()
messages = [
    {"role": "system", "content": "당신은 간결한 한국어 비서입니다."},
]

def chat(user_input: str) -> str:
    messages.append({"role": "user", "content": user_input})
    resp = client.chat.completions.create(
        model="gpt-4o",  # 현재 제공 모델은 공식 문서 확인 권장
        messages=messages,
    )
    reply = resp.choices[0].message.content
    messages.append({"role": "assistant", "content": reply})
    return reply

요지는, 어떤 공급사든 "히스토리 누적 → 통째로 전송 → 응답 누적"의 루프는 같다는 것입니다. 따라서 챗봇의 저장·복원 계층(앞의 Laravel 예제)은 공급사에 거의 독립적으로 설계할 수 있고, 어댑터만 갈아끼우면 됩니다. 다만 임베딩이 필요한 RAG라면 주의가 필요합니다 — Anthropic에는 임베딩 API가 없으므로, 임베딩은 OpenAI의 text-embedding-3-small/-large나 Voyage AI로 만들고 생성만 Claude로 하는 조합을 씁니다.

긴 시스템 프롬프트는 프롬프트 캐싱으로 비용을 줄인다

멀티턴 봇은 같은 시스템 프롬프트(페르소나, 규칙, 도메인 지식)를 매 턴 반복 전송합니다. 이 고정 접두가 길다면 프롬프트 캐싱으로 비용을 크게 줄일 수 있습니다. 시스템 텍스트 블록에 cache_control을 달면, 동일 접두에 대해 캐시가 적중하여 읽기 비용이 기본 입력가의 약 0.1배로 떨어집니다(쓰기는 약 1.25배). 캐시는 접두 일치(prefix match)이므로, 자주 안 바뀌는 내용을 앞에, 매 턴 바뀌는 내용을 뒤에 배치하는 것이 핵심입니다.

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": LONG_SYSTEM_PROMPT,  # 긴 고정 지침/도메인 지식
            "cache_control": {"type": "ephemeral"},  # 여기까지 캐시
        }
    ],
    messages=messages,  # 매 턴 바뀌는 부분
)

# 캐시 적중 확인
print(resp.usage.cache_read_input_tokens)   # 0보다 크면 적중
print(resp.usage.cache_creation_input_tokens)

주의할 점: 캐시 가능한 최소 접두 길이가 있어(Opus 4.8은 약 4096 토큰) 그보다 짧으면 조용히 캐시되지 않습니다. 또한 시스템 프롬프트에 datetime.now() 같은 매 요청 변하는 값을 끼워 넣으면 접두가 매번 달라져 캐시가 절대 적중하지 않습니다. cache_read_input_tokens가 계속 0이라면 이런 "조용한 무효화" 요인을 의심하세요.

자주 묻는 질문 (FAQ)

Q. Claude에 conversation_id 같은 걸 넘기면 서버가 대화를 기억해주지 않나요?
아니요. Messages API는 완전히 stateless입니다. 서버측 대화 핸들이 없으며, 맥락은 전적으로 여러분이 매 요청에 messages 배열을 통째로 보냄으로써 유지됩니다. "기억"은 클라이언트(여러분의 앱)가 만드는 환상입니다. 단, 서버가 상태를 관리하는 별도 제품인 Managed Agents(세션 기반)는 예외이지만, 이는 일반 Messages API와는 다른 surface입니다.

Q. 시스템 프롬프트를 messages 배열 안에 user나 assistant로 넣어도 되나요?
Claude에서는 권장하지 않습니다. 시스템 프롬프트는 별도 system 파라미터로 넘기세요. OpenAI는 messages의 첫 항목으로 role: "system"을 쓰지만 Claude의 규약은 다릅니다. 한편 Opus 4.8 등 일부 모델은 대화 도중에 운영자 지침을 주입하는 mid-conversation system 메시지(messagesrole: "system" 추가, 베타)를 지원하지만, 초기 페르소나는 top-level system으로 두는 것이 캐시 친화적입니다.

Q. "roles must alternate" 400 에러가 자꾸 납니다. 왜죠?
userassistant 메시지가 연속으로 들어갔기 때문입니다. 첫 메시지는 반드시 user여야 하고, 이후 두 역할이 번갈아 나와야 합니다. 흔한 원인은 (1) API 호출 실패로 assistant 응답이 저장되지 않아 user가 연속된 경우, (2) 사용자가 답변 전에 메시지를 연달아 보낸 경우입니다. DB 복원 시 연속된 동일 역할을 하나로 병합하거나, 입력을 큐로 합치세요.

Q. 대화가 길어지면 매번 전체를 다 보내야 하나요? 비효율 아닌가요?
맥락 유지를 위해선 원칙적으로 그렇습니다. 비용을 줄이는 방법은 세 가지입니다. (1) 오래된 턴을 요약으로 압축, (2) 슬라이딩 윈도로 최근 N턴만 유지, (3) 고정 시스템 프롬프트에 프롬프트 캐싱 적용. Opus 4.8/4.7/4.6, Sonnet 4.6에서는 서버측 컴팩션(베타 헤더 compact-2026-01-12)으로 이 압축을 자동화할 수도 있습니다.

Q. 도구 호출을 했더니 모델이 다음 턴에 자기가 뭘 호출했는지 잊습니다.
도구 호출의 assistant 턴(tool_use 포함 response.content 전체)과 그 결과 user 턴(tool_result)을 messages에 누적하지 않았기 때문입니다. 도구 호출도 결국 대화에 쌓이는 두 개의 턴이며, 이 누적을 빠뜨리면 맥락이 끊깁니다. 또한 tool_resulttool_use_id가 해당 tool_use.id와 정확히 일치해야 합니다.


AI 개발이 필요하신가요?

DevTeam은 OpenAI·Claude 등 AI API 연동, 챗봇, RAG, 업무 자동화를 설계부터 배포까지 구현합니다. AI 연동 개발 또는 무료 견적 문의.

개발 파트너가 필요하신가요?

DevTeam은 MVP·웹·앱·AI 개발을 설계부터 배포·운영까지 한 팀이 책임집니다.