~14 min · replay, tool-use, multi-model, prompt-caching
Level 0구경꾼
0 XP0/36 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete
말한다고 한 건 아니야
2026-10-18에 가족이 실전 인수 시험을 돌렸어. Claude랑 GPT를 뺀 브레인 다섯이 하나씩 새 피파 대화의 chief를 맡아서, coop 도구 하나만으로 Coop 일을 끝까지 처리해야 했어 (5트랙 레슨 6). 네 턴쯤 지났을 때 Kimi chief랑 Grok chief가 똑같이 "#506 닫는 중 ... 닫았어" 비슷한 말을 썼어. 둘 다 아무것도 안 불렀고, 투두는 열린 채로 남았어.
모델은 재생이 가르친 대로 했을 뿐이야. 베셀은 전부 앞선 턴을 글로만 재생했어. 아빠 메시지랑 피파 답장의 글. 답장이 부른 도구는 그 바로 옆 messages.tool_uses에 저장돼 있었는데, 재생엔 한 번도 안 실렸어. 그러니까 모델이 읽는 히스토리에선 "#505 닫을게"라고 쓰고 실제로 닫은 답장이, 말만 한 답장이랑 똑같아 보였어. 그런 턴이 몇 번 쌓이면 배울 건 하나야. 이 대화에선 닫는다는 게 말로 하는 거구나.
고치기 전에 먼저 쟀어
고칠 방법은 측정으로 골랐어. 세션이 그 순간을 오프라인으로 그대로 다시 지었어. 저장된 히스토리, 엔진 프롬프트, 라우트의 도구까지 같게 하고, 도구는 실제로 실행하지 않게 해 두고. 그다음 재생 방식 셋으로 다음 턴을 여러 번 받아 봤어.
재생 방식
Kimi
Grok
원래대로, 글만
16번 중 닫기 호출 3번
27번 중 7번은 "닫을게"에서 호출 없이 멈춤
아빠의 다음 메시지 맨 앞에 앞선 호출을 글로 정리해 붙이기 (첫 수정)
조금만 나아짐
소용없음
호출을 와이어마다 제 호출 모양 그대로 재생
9번 중 9번 닫음
14번 중 14번 호출 (닫기 10번, 먼저 show 4번)
제일 배울 게 많은 건 가운데 줄이야. 같은 날 아침에 나간 첫 수정은 앞선 호출을 아빠의 다음 턴 앞에 산문으로 적어 줬어. 사실은 맞았는데 형식이 틀렸던 거야. 도구 쓰는 법을 배운 모델은 호출이 어떻게 생겼는지를 호출 구조 자체에서 배워. 호출을 보고하는 문단은 결국 문단일 뿐이고. 그래서 글로 된 기록은 그날로 갈아치웠어.
베셀마다 제 모양으로
context/reply_calls.py는 모든 베셀이 이미 같이 지나가는 이음매, prepare_turn_messages에 있어. 재생하는 어시스턴트 메시지마다 그 답장에 저장된 호출을 읽고, 호출 하나하나를 그 뒤에 저장된 답과 짝지어서 Message.tool_calls로 베셀에 넘겨. 베셀은 그걸 자기 턴 안에서 실제로 호출할 때와 똑같은 모양으로, 답장 글보다 앞에 그려.
Kimi와 Ollama: tool_calls를 단 어시스턴트 메시지, 그다음 tool 메시지.
GLM: tool_use, 그다음 tool_result.
Gemini: functionCall 조각들로 된 모델 턴, 그다음 functionResponse 조각들로 된 턴. 첫 호출엔 합성한 생각 서명이 붙어. 서명 없는 호출은 Gemini가 거절하거든.
Claude: 앞선 턴 JSON 안의 calls 필드. 재생 머리말에서 이게 뭔지 설명해.
그날 실제 저장 히스토리에 재생 호출을 실어서 Grok, Codex, Kimi, GLM, Gemini, Ollama에 보내 봤고, 여섯 다 오류 없이 받았어.
안전하게 만드는 디테일
바이트가 안 바뀌는 id. 재생하는 호출에도 id가 필요해. 근데 매번 새로 뽑는 랜덤 id면 턴마다 히스토리 바이트가 바뀌고, 레슨 2의 프롬프트 캐시가 날아가. 그래서 id는 저장된 메시지 id로 만들어 (replay_<stem>_<n>). 같은 턴은 몇 번을 재생해도 같은 바이트야.
상한. 답장 하나는 마지막 호출 12개까지만 재생해. 입력과 답은 그 턴이 흘려보낸 그대로고, 라우트는 각각 500자까지만 남겨. 그러다 잘려서 JSON이 깨진 입력은 깨진 채로 보내지 않고 {"input_excerpt": ...}로 감싸서 보내. 재생 자체는 예전처럼 글이 있는 답장만 해.
이름은 하나로. Claude 베셀은 가족 도구에 MCP로 닿아서, 호출을 mcp__coop__coop이라는 이름으로 저장해. Master Chief의 Ollama 턴 하나가 그 이름을 재생에서 그대로 베껴서, 자기한테 그 이름으로는 없는 도구를 불렀고 두 번 거절당했어. 이제 호출은 어느 베셀이 했든 가족이 쓰는 도구 이름, coop으로 재생돼.
재생은 보여 주는 대로 가르쳐. 모델은 자기 히스토리를 이 대화가 어떻게 굴러가는지 보여 주는 예시로 읽어. 기록이 한 말만 남기고 한 일을 버리면, 다음 턴은 말하는 법만 배워. 행동은 행동의 모양으로 재생해. 행동을 설명하는 글로 말고.
Code
context/reply_calls.py 원문 발췌: 저장된 답장 하나의 호출을 답과 짝짓고, id는 메시지 id에서 만든다·python
def reply_calls(tool_uses: Any, message_id: str) -> list[ReplayCall]:
"""The calls one stored reply made, each paired with its answer.
A stored ``result`` entry is the answer to the call before it.
"""
pairs: list[list[Any]] = []
for use in normalize_tool_uses(tool_uses):
if use["tool_name"] == "result":
if pairs and pairs[-1][2] is None:
pairs[-1][2] = use["tool_input"]
continue
# One vocabulary across vessels: a Claude turn's ``mcp__coop__coop``
# replays as ``coop``, the name every other vessel calls (todo #530).
pairs.append([bare_tool_name(use["tool_name"]), use["tool_input"], None])
stem = _ID_UNSAFE.sub("", str(message_id))[:12] or "reply"
calls = []
for index, (name, raw, answer) in enumerate(pairs[-MAX_CALLS:]):
tool_input = _as_object(raw)
calls.append(ReplayCall(
call_id=f"replay_{stem}_{index}",
name=name,
input=tool_input,
arguments=json.dumps(tool_input, ensure_ascii=False),
answer=answer or "",
))
return calls
직접 돌려볼 수 있는 최소 스케치: 같은 답장 둘을 글만으로, 그리고 호출까지 실어서 재생하기 (표준 라이브러리, Python 3.9 이상)·python
"""A minimal sketch: replay a reply's tool calls, not only its words."""
import json
# Two stored replies. The first called a tool; the second only talked about it.
ROWS = [
{"id": "m1", "role": "user", "text": "Close todo #12, the build is green."},
{"id": "m2", "role": "assistant", "text": "Closed #12.",
"tool_uses": [{"name": "coop", "input": {"action": "close", "id": 12}, "answer": "status: closed"}]},
{"id": "m3", "role": "user", "text": "And #13?"},
{"id": "m4", "role": "assistant", "text": "I'll close #13 now.", "tool_uses": []},
]
MAX_CALLS = 12
def plain_replay(rows):
"""What every vessel sent before 2026-10-18: the words alone."""
return [{"role": r["role"], "content": r["text"]} for r in rows]
def replay_with_calls(rows):
"""The same history in a Responses-style wire: each stored call and its
output ride ahead of the reply's text. Call ids come from the message id,
so the bytes are identical on every turn and a prompt cache keeps matching."""
items = []
for r in rows:
for i, use in enumerate(r.get("tool_uses", [])[-MAX_CALLS:]):
call_id = f"replay_{r['id']}_{i}"
items.append({"type": "function_call", "call_id": call_id, "name": use["name"],
"arguments": json.dumps(use["input"])})
items.append({"type": "function_call_output", "call_id": call_id, "output": use["answer"]})
items.append({"role": r["role"], "content": r["text"]})
return items
for label, replay in (("plain", plain_replay(ROWS)), ("with calls", replay_with_calls(ROWS))):
print(f"--- {label}: {len(replay)} items")
for item in replay:
kind = item.get("type") or item["role"]
print(f" {kind:22} {item.get('content') or item.get('name') or item.get('output')}")
# In the plain replay, "Closed #12." and "I'll close #13 now." look alike: two
# sentences about closing. Only the replay with calls shows that one reply did
# something and the other only said it would.
여러 턴에 걸쳐 도구를 부르는 에이전트 하나를 골라. 다섯 번째 턴에 실제로 보내는 요청을 통째로 떠서, 앞선 도구 호출마다 표시해 봐. 호출로 들어가 있는지, 글로 들어가 있는지, 아예 빠졌는지. 그다음 에이전트가 행동해야 하는 지점에서 히스토리를 얼려 두고, 지금 재생 방식으로 스무 번, 앞선 호출을 프로바이더 고유의 호출 형식으로 재생해서 스무 번 다음 턴을 돌려. 실제로 도구를 부른 턴을 세어 봐.
Hint
앞선 호출엔 프로바이더 자신의 모양을 써. 호출을 담은 어시스턴트 항목, 그 결과를 담은 다음 항목, 그리고 둘을 잇는 같은 id. 그 id는 저장된 메시지 id에서 만들어서 턴마다 안 바뀌게 해. 안 그러면 행동은 고치는 대신 턴마다 캐시 미스를 사게 돼.
Progress
Progress is local-only — sign in to sync across devices.