AI 에이전트 — 모델이 아니라 그래프 · 스트리밍 — 무엇을 언제 내보내는가 · 이론
스트리밍은 글자가 아니라 단계를 흘려보내는 것이다
한 줄 요약
에이전트에서 흘려보낼 것은 토큰만이 아니다. 지금 어느 노드에 있는지, 무엇이 새로 정해졌는지, 끝난 뒤의 상태 전체가 각각 다른 모드로 나오고, 고르는 기준이 다르다.
왜 이게 필요했나
회의록을 정리해 주는 에이전트를 붙였더니 사용자들이 "멈춘 것 같다" 고 했다. 실제로는 잘 돌고 있었다. 12초쯤 걸리는 일인데 그동안 화면에 아무것도 없었을 뿐이다.
처음 든 생각은 "토큰을 한 글자씩 흘리자" 였다. 그런데 이 에이전트는 모델을 마지막에 한 번만 부른다. 흘릴 토큰이 생기는 시점이 이미 끝나 가는 시점이다.
필요한 것은 다른 것이었다. "지금 회의록을 문단으로 나누는 중입니다", "결정 사항 4건을 찾았습니다" 처럼 단계와 중간 결과를 흘려보내는 것. 그리고 그건 모델이 아니라 그래프가 알고 있다.
어떻게 동작하나
[Streaming](https://docs.langchain.com/oss/python/langgraph/streaming) 문서가 정하는 대로 stream(입력, stream_mode=...) 한 줄이면 된다. 이 실습 이미지의 langgraph 0.2.60 에서 직접 재어 본 모양은 이렇다.
| 모드 | 이벤트 하나의 모양 | 몇 개 나오나 |
| --- | --- | --- |
| values | 상태 전체(딕셔너리) | 입력 상태 1개 + 수퍼스텝마다 1개 |
| updates | {노드이름: 그 노드가 돌려준 갱신} | 노드마다 1개 |
| debug | {"type": "task"/"task_result", "step": 번호, "payload": {...}} | 노드마다 2개 |
| custom | 노드가 writer(...) 로 넣은 것 그대로 | 노드가 부른 만큼 |
| 목록으로 주면 | (모드이름, 값) 튜플 | 고른 모드들의 이벤트를 섞어서 |
여기서 헷갈리기 쉬운 곳이 둘이다.
첫째, updates 는 수퍼스텝 단위가 아니라 노드 단위다. 나란히 도는 노드 둘이 같은 수퍼스텝에 있어도 이벤트는 따로 하나씩 나온다. 반면 values 는 수퍼스텝이 끝난 뒤 한 번이라, 나란한 노드 둘의 결과가 합쳐진 뒤 나온다.
둘째, debug 의 step 이 수퍼스텝 번호다. 나란히 도는 노드들은 같은 번호를 갖는다. 그래서 "지금 몇 번째 단계이고 그 단계에서 무엇이 함께 도는가" 를 알고 싶으면 이 모드를 쓴다.
갱신만 모으면 상태를 다시 세울 수 있는가
할 수 있다. 다만 리듀서를 알아야 한다.
for event in app.stream(입력, stream_mode="updates"): for node, update in event.items(): for key, value in update.items(): state[key] = value # ← 이어 붙이는 열쇠에서 틀린다trace 처럼 이어 붙이는 열쇠를 이렇게 덮어쓰면 마지막 한 칸만 남는다. 그래프 안에서는 리듀서가 해 주던 일을, 밖에서 재구성할 때는 직접 해야 한다.
그런데 리듀서를 제대로 흉내 내도 여전히 완전히 같아지지는 않는다. 이 실습 이미지에서 직접 재어 보면 이렇다.
그래프에 노드를 split → keypoints → actions → compose 순으로 더함updates 로 받아 이어 붙인 trace : ['split', 'keypoints', 'actions', 'compose']values 의 마지막 이벤트의 trace : ['split', 'actions', 'keypoints', 'compose']나란히 도는 두 노드의 자리가 바뀌어 있다. updates 는 노드를 그래프에 더한 순서로 나오고, 리듀서는 같은 수퍼스텝의 값을 노드 이름 순으로 합치기 때문이다. 둘 다 매번 같은 답을 주지만(반복해도 흔들리지 않는다) 서로 다르다.
그래서 규칙은 이렇다 — 순서가 뜻을 가지는 자료라면 updates 로 재구성한 것을 최종본으로 쓰지 않는다. 화면을 그리는 데는 괜찮지만, 저장하거나 비교할 최종 상태는 values 의 마지막 이벤트(또는 invoke 의 답)를 쓴다.
그 점을 빼면 실무의 선택은 보통 이렇게 갈린다 — 화면이 누적해서 그려야 하는 것(진행 로그, 부분 목록)은 updates 로 받아 프런트가 합치고, 최종 상태를 통째로 갱신하면 되는 것은 values 의 마지막 이벤트를 쓴다. values 는 매번 상태 전체가 오므로 전송량이 크다 — 상태에 큰 값이 들어 있으면 그 값이 수퍼스텝마다 다시 나간다.
노드가 직접 내보내는 조각
상태에 남기고 싶지는 않은데 사람에게는 보여 주고 싶은 것이 있다. "3번째 문단 처리 중" 같은 진행 표시다. 상태에 넣으면 체크포인트에 실리고 재개할 때도 따라온다.
custom 모드가 그 자리다. 노드가 writer 인자를 받아 직접 내보낸다.
def compose(state, writer: StreamWriter): writer({"stage": "compose", "points": len(state["points"])}) return {"summary": ...}writer(...) 로 넣은 것은 상태에 남지 않고 듣는 쪽으로만 간다. 그래서 진행 표시·부분 텍스트·디버그 힌트에 알맞다.
흘려보낸 것과 invoke 의 답은 같다
values 의 마지막 이벤트는 invoke() 가 돌려주는 것과 같다. 직접 대조해 확인할 수 있고, 같다는 것이 중요하다 — 화면은 스트리밍으로 그리고 기록은 invoke 로 따로 남기는 식으로 두 번 돌릴 이유가 없다는 뜻이기 때문이다.
현장에서 만나는 모습
첫째, 흘릴 것이 없는 시점에 흘리려 한다. 모델을 마지막에 한 번 부르는 그래프에서 토큰 스트리밍만 붙이면 앞의 침묵은 그대로다. 단계를 흘려보내야 한다.
둘째, values 로 큰 상태를 매 단계 내보낸다. 상태에 원문이 들어 있으면 그 원문이 수퍼스텝마다 다시 나간다. 화면이 필요로 하는 것만 updates 나 custom 으로 보내는 편이 낫다.
셋째, 갱신을 덮어쓰기로 합친다. 위의 재구성 함정이다. 발자국이 한 칸만 남는다.
넷째, 진행 표시를 상태에 넣는다. 체크포인트가 커지고, 재개했을 때 옛 진행 표시가 되살아난다.
실무에서 진짜 중요한 것
- 무엇을 보여 줄지 먼저 정하고 모드를 고른다. 모드가 먼저가 아니다.
updates는 노드 단위,values는 수퍼스텝 단위,debug는 수퍼스텝 번호까지.- 갱신만으로 상태를 세우려면 리듀서를 알아야 한다.
- 상태에 남기지 않을 것은
custom으로 내보낸다.
다음 실습에서 할 것
/root/work/agstream/stream.py 를 한 단계씩 키운다. 같은 그래프를 values·updates·debug·여러 모드 함께·custom 다섯 가지로 흘려보내며 이벤트의 수와 모양을 직접 센다. 갱신만 모아 마지막 상태를 다시 세워 보고, 이어 붙이는 열쇠에서 무엇이 틀어지는지 확인한다. 마지막으로 흘려보낸 마지막 상태와 invoke 의 답이 같은지 대조하고, 무엇을 언제 보여 줄지 정리해 기록으로 남긴다.