분기형 채팅
분기형 채팅 (Branching chat)
AI 에이전트와의 대화는 거의 항상 직선이 아니에요. 질문을 다듬고 싶을 수도 있고, 마음에 안 드는 응답을 다시 생성하고 싶을 수도 있고, 체크포인트 기록을 잃지 않으면서 다른 대화 경로를 탐색하고 싶을 수도 있죠. 분기형 채팅(branching chat) 은 LangGraph 체크포인트를 포크(fork) 지점으로 사용해서, 편집이나 재생성할 때마다 선택한 메시지의 부모 체크포인트에서 새 실행을 제출하는 방식이에요.
이 기능은 LangGraph Agent Server가 필요해요. 에이전트를 langgraph dev로 로컬에서 실행하거나 LangSmith에 배포하면 이 패턴을 쓸 수 있습니다.
분기형 채팅이란? (What is branching chat?)
분기형 채팅은 대화를 단순한 평면 리스트가 아니라 체크포인트가 찍힌 타임라인으로 취급해요. 각 메시지에는 그 메시지가 만들어지기 직전 체크포인트를 가리키는 메타데이터가 달려 있죠. 메시지를 편집하거나 응답을 재생성하면 그 체크포인트에서 새 실행이 제출됩니다. 주요 기능은 이렇습니다.
- 사용자 메시지 편집 — 이전 프롬프트를 다시 쓰고 그 지점부터 에이전트를 다시 실행해요.
- AI 응답 재생성 — 같은 입력에 대해 에이전트가 다른 답을 만들도록 요청하죠.
- 기록 검사 — 분기 타임라인이 필요할 때 LangGraph 클라이언트로 체크포인트를 불러와요.
스트림 메타데이터 설정 (Set up stream metadata)
메시지에는 루트 스트림을 사용하고, 각 메시지를 렌더링하는 컴포넌트에서 메시지별 체크포인트 메타데이터를 읽어오세요. 메타데이터에는 포크할 부모 체크포인트 ID가 포함되어 있어요. 코드 예제는 타입 안전한 스트림 상태를 위해 useStream<typeof myAgent>를 사용합니다. 타입 추론(Type inference)은 Python 또는 JavaScript 백엔드 문서를 참고하세요.
useMessageMetadata(stream, messageId) 헬퍼는 특정 메시지 하나에 대한 MessageMetadata를 반환해요. 메시지 컨트롤을 렌더링하는 컴포넌트에서 이걸 호출하면 메타데이터가 그 메시지 ID에 한정됩니다.
import type { BaseMessage } from "langchain";
import { useState } from "react";
import { useMessageMetadata, useStream } from "@langchain/react";
function Chat() {
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "simple_agent",
});
return stream.messages.map((message) => (
<MessageWithForkControls
key={message.id}
stream={stream}
message={message}
/>
));
}
function MessageWithForkControls({
stream,
message,
}: {
stream: ReturnType<typeof useStream>;
message: BaseMessage;
}) {
const metadata = useMessageMetadata(stream, message.id);
const checkpointId = metadata?.parentCheckpointId;
const [editedText, setEditedText] = useState(message.text);
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (!checkpointId) return;
stream.submit(
{ messages: [{ type: "human", content: editedText }] },
{ forkFrom: { checkpointId } }
);
}}
>
<textarea
value={editedText}
onChange={(event) => setEditedText(event.target.value)}
/>
<button disabled={!checkpointId || editedText === message.text}>
Submit edited branch
</button>
</form>
);
}
parentCheckpointId는 그 메시지 직전의 체크포인트예요. 편집과 재생성의 포크 지점으로 사용하죠. 사용자 메시지를 편집하고 대화를 포크하려면:
- 메시지 메타데이터에서
parentCheckpointId를 가져와요. forkFrom: { checkpointId }로 편집된 메시지를 제출해요.- 에이전트가 그 지점부터 다시 실행됩니다.
function handleEdit(
stream: ReturnType<typeof useStream>,
originalMsg: HumanMessage,
metadata: MessageMetadata | undefined,
newText: string
) {
if (!metadata?.parentCheckpointId) return;
stream.submit(
{
messages: [{ type: "human", content: newText }],
},
{ forkFrom: { checkpointId: metadata.parentCheckpointId } }
);
}
편집이 끝나면:
- 에이전트가 업데이트된 메시지로 포크 지점부터 다시 실행돼요.
- 원래 경로는 스레드 기록에 그대로 남아 있습니다.
응답 재생성 (Regenerate a response)
입력을 바꾸지 않고 AI 응답을 재생성하려면:
- AI 메시지 메타데이터에서
parent_checkpoint를 가져와요. - 빈 입력과
forkFrom: { checkpointId }로 제출해요. - 에이전트가 그 지점부터 새 응답을 만들어냅니다.
function handleRegenerate(
stream: ReturnType<typeof useStream>,
metadata: MessageMetadata | undefined
) {
if (!metadata?.parentCheckpointId) return;
stream.submit(undefined, {
forkFrom: { checkpointId: metadata.parentCheckpointId },
});
}
재생성할 때마다 해당 위치의 AI 메시지에 대해 새 경로가 만들어져요.
재생성은 비결정적(non-deterministic) 에이전트에서 특히 유용합니다. LLM 출력은 온도(temperature)에 따라 달라지기 때문에, 같은 프롬프트를 재생성하면 의미 있게 다른 응답이 나오는 경우가 많죠.
내부적으로 분기(branching)가 동작하는 방식 (How branching works under the hood)
LangGraph는 모든 상태 전환을 체크포인트(checkpoint) 로 영속화해요. forkFrom으로 제출하면 백엔드가 현재 대화에 이어붙이는 대신 그 지점부터 새 실행 경로를 시작합니다. 그 결과는 트리 구조가 돼요.
User: "What is React?"
└─ AI: "React is a JavaScript library..." (branch A)
└─ AI: "React is a UI framework..." (branch B, regenerated)
User: "Tell me about hooks" (branch A)
└─ AI: "Hooks are functions..."
User: "Tell me about JSX" (edited from branch A)
└─ AI: "JSX is a syntax extension..."
각 경로는 체크포인트 스토어에 저장돼요. 체크포인트 전체를 가로지르는 별도의 타임라인 뷰를 만들고 싶다면 stream.client.threads.getHistory(threadId)를 사용하면 됩니다.
모범 사례 (Best practices)
- 메시지 근처에서 메타데이터 읽기 — 메시지 컨트롤을 렌더링하는 컴포넌트에서
useMessageMetadata를 호출하세요. - 호버 시 포크 컨트롤 표시 — UI를 깔끔하게 유지하려면 편집·재생성 버튼을 호버할 때만 나타내세요.
- 요청 시 기록 새로고침 — 타임라인을 렌더링하거나 포크가 안정된 후에만
client.threads.getHistory()를 호출하세요. - 스트리밍 중 컨트롤 비활성화 — 에이전트가 응답을 스트리밍하는 동안엔 편집이나 재생성을 막아야 해요. 활성화 전에
stream.isLoading을 확인하세요. - 취소 시 편집 텍스트 복원 — 사용자가 편집을 시작했다가 취소하면 textarea를 원본 메시지 내용으로 되돌리세요.
- 깊은 체크포인트 트리로 테스트 — 편집·재생성을 자주 하는 사용자는 경로가 많아질 수 있어요. 타임라인 렌더링이 성능을 유지하는지 확인하세요.