第13章 考えている様子を見せる — ストリーミング+道具呼び出しの表示
📖 この章のゴール:これまでの
create(全部できてから一気に表示)をstream(できたそばから流す)に替えて、返事を1文字ずつターミナルに出せるようになる。さらに、AIが道具を使う瞬間も画面に出して、「いま何をしているか」が見えるエージェントにする。 ← 目次・はじめにへもどる
📱 Claude Code ではこう見える
Claude Code に「このテスト直して」と頼むと、画面はだまって固まったりしません。考えている言葉がスルスルと流れて出てきて、途中で
Running: npm test
のように、いま道具(コマンド)を実行している様子まで見せてくれます。テストが終わると、また考えの続きが流れ出す——という具合です。
これは飾りではありません。エージェントは1回の依頼で何往復もループします(第4章・第12章)。各往復に数秒〜十数秒かかることもある。その間ずっと真っ黒な画面だったら、「止まったのかな? もう一回押す?」と不安になります。流しながら見せるだけで、その不安が消えるのです。
前作(ChatGPTクローン)の第11章で、ブラウザに「1文字ずつ」出す SSE をやりました。あれは「サーバー → ブラウザ」に断片を流す話。今回は舞台がターミナルに変わるだけで、考え方はそっくりです。しかも——今回のほうがずっと簡単。ブラウザもサーバーも無いので、process.stdout.write()(画面に書き出す)で直接ターミナルに流せます。
🤔 なぜ「流す」だけで体感が変わるのか
だまって待たされるのが一番つらい(🟢 基礎)
これまでの章では、こう書いてきました(第2章で確立した形)。
const res = await anthropic.messages.create({ model: MODEL, max_tokens: 1024, system: SYSTEM_PROMPT, messages, tools });
printText(res); // 全部できてから、一気に表示
create は「AIが返事を全部書き終わるまで待って、完成品を一気に受け取る」やり方です。短い返事ならいいのですが、長い説明や、何往復もするタスクだと——書き終わるまで画面はまっさら。利用者からは「固まった」ようにしか見えません。
🍽 たとえ話(前作と同じ皿の話):料理の運び方
create=コース料理を全部できてから、まとめて1回で運ぶ。最後の皿が焼けるまで、テーブルは空っぽ。stream=できた皿から、順番にどんどん運ぶ。待っている人は「お、来た来た」と楽しめる。料理(返事)の総量も、料金も同じ。運び方を変えるだけで、待ち時間の体感がまるで違います。
LLMは、実は返事を少し書くたびに、その断片(かけら)を出せます。stream は、その断片を届いたそばから受け取れる呼び方。受け取るたびに process.stdout.write() で画面に足していけば、ターミナルに「1文字ずつ」流れて見えます。
📝 この「今回増えたぶんの小さな断片」のことを デルタ(delta=差分) と呼びます。前作のSSEで
delta.contentを拾ったのと同じ言葉です。
「道具を使う瞬間」も見せる(🟢 基礎)
ストリーミングのもう一つの大事な仕事は、道具を使うときに「いま○○をやってます」と知らせることです。
第4章のループを思い出してください。AIは「考える → 道具を使う → 結果を見て、また考える」をぐるぐる回します。道具を使う往復(stop_reason === "tool_use")では、AIはまだ最終回答を書いていません。だから流す言葉も少ない。ここで何も出さないと、ちょうど道具の実行中(ファイルを読む・コマンドを走らせる)に画面が止まって見えます。
そこで、道具を使うブロックを見つけたら
[道具を使います: read_file]
のように画面に出します。Claude Code の Running: npm test と同じ役割です。「黙って止まった」ではなく「いま read_file を使っているんだな」と分かる。これだけで安心感がまるで違います。
💡 背骨の確認:流して見せるのは UX(使い心地)の工夫であって、ループそのものは第4章から1ミリも変わりません。判定(
stop_reason/tool_use)も、道具を実行して結果を返す流れも、全部そのまま。変えるのは「受け取り方」だけです。
🛠 こう作る — create を stream に差し替える(🟢 基礎)
やることは1か所だけ。第4章のループの中の messages.create({...}) を、messages.stream({...}) + 断片を流す + finalMessage() で完成品を受け取るに置き換えます。
ステップ1:1往復ぶんを「流す」関数にする
まず、LLMを1回呼んで流しながら完成した返事を受け取る部分を、小さな関数に切り出します。
async function streamTurn(): Promise<Anthropic.Message> {
const stream = anthropic.messages.stream({
model: MODEL,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages,
tools,
});
stream.on("text", (delta) => process.stdout.write(delta)); // 届いた文字を即・画面へ
return await stream.finalMessage(); // 流し終わったら、完成した Message を受け取る
}
1行ずつ読むと:
anthropic.messages.stream({...}):これまでのcreateをstreamに替えただけ。渡す中身(model/max_tokens/system/messages/tools)はまったく同じ。toolsも忘れず渡す(道具メニューは流しても必要)。stream.on("text", (delta) => ...):「テキストの断片が届くたびに、この処理を呼んで」という登録。delta(デルタ)が今回増えた文字。process.stdout.write(delta):その断片をターミナルに即・書き出す。console.logと違って改行を付けないので、断片がつながって「1文字ずつ」に見えます。これが前作のres.write(...)(ブラウザへ流す)にあたる、ターミナル版。return await stream.finalMessage():流し終わったあと、完成したMessage(stop_reasonやcontentが全部入った、createと同じ形のもの)を受け取って返す。ループの判定はこの完成品を使うので、これがキモ。
⚠️ ここで
.on()をnew Promise()で自分で包みたくなりますが、やめてください。SDKが用意したfinalMessage()が「流し終わるまで待って、完成品を返す」を全部やってくれます(中で待つ・組み立てる・エラーを上げる、を面倒みてくれる)。自前の Promise は二重管理になってバグの元です。
ステップ2:ループに組み込む(道具の表示も足す)
第4章の runAgent を、streamTurn() を使う形に書き換えます。ループの骨格は第4章と同じ。違いは「流す」と「道具を使う前に一言出す」の2点だけ。
const MAX_STEPS = 10; // 暴走防止の上限(第4章から)
async function runAgent(userInput: string) {
messages.push({ role: "user", content: userInput });
for (let step = 0; step < MAX_STEPS; step++) {
const res = await streamTurn(); // ★ 流しながら1往復
process.stdout.write("\n"); // 流し終わりに1回だけ改行
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") return; // 最終回答だった → 終了(もう流して表示済み)
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
console.log(`[道具を使います: ${block.name}]`); // ★ いま何をするかを見せる
const result = await executeTool(block.name, block.input);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
}
messages.push({ role: "user", content: toolResults });
}
}
1行ずつ読むと:
const res = await streamTurn();:ステップ1の関数を呼ぶ。画面にはもう言葉が流れている。返ってくるresは完成したMessage。process.stdout.write("\n");:断片には改行を付けていなかったので、1往復ぶん流し終わったらここで1回だけ改行して見た目を整える。messages.push({ role: "assistant", content: res.content });:完成した返事を会話履歴に積む。第4章とまったく同じ。if (res.stop_reason !== "tool_use") return;:道具を使わない=最終回答。createの頃はprintText(res)で表示してから終わったが、今回はもう流して表示し終えているので、表示せずそのままreturn。console.log(\[道具を使います: ${block.name}]`);:道具ブロックを見つけたら、**実行する前に道具名を出す**。block.nameはread_fileなどの道具名。Claude Code のRunning: …` にあたる一言。const result = await executeTool(block.name, block.input);:実際に道具を動かす。block.inputはSDKがパース済みのオブジェクト(第3章のことばメモのとおり、JSON.parse不要)。ここも第4章のまま。messages.push({ role: "user", content: toolResults });:道具の結果を履歴に積んで、次の往復へ。ループが続く=またstreamTurn()が呼ばれて、続きが流れ出す。
✅ 動かし方:第2章のCLI入力ループ(
rl.question("> "))はそのまま。runAgentの中身だけ上の形に替えてnpx tsx agent.tsで起動し、「このフォルダのファイルを一覧して」などと頼んでみてください。返事がスルスルと流れ、途中で[道具を使います: list_files]が出て、また続きが流れれば成功です。
💡 他の言語でも:本質は「
createをstreamに替えて、断片をon("text")で受けて、finalMessage()で完成品を待つ」だけ。手元のLLMに「このTypeScriptをPythonで書き換えて。ストリーミングは公式SDKの stream を使って」と頼めば、同じ仕組みが他の言語でも作れます。
⚠️ ハマりどころ
.on()をnew Promise()で包んでしまう(🟢):上でも書いたとおり、finalMessage()を使えば自前のPromiseは不要。包むと「いつ完成したか」を二重に管理することになり、awaitの取りこぼしや握りつぶしたエラーの温床になります。受け取りはon("text", ...)、完成待ちはfinalMessage()、と役割を分けるのが正解。- 道具の
inputを“途中で”parseしようとする(🟢→🔧):道具の入力(tool_useのinput)は、流れている最中は中途半端なJSONの断片で届きます。途中の断片を拾ってJSON.parseすると必ず壊れます(まだ閉じカッコが来ていない、など)。途中では絶対にparseしない。finalMessage()で受け取った完成品のblock.inputを使えば、SDKが組み立て終わった完全なオブジェクトになっています。だから上のコードは「流すのはテキストだけ、道具の中身は完成後に読む」という形にしてあります。 - まとめてドンと出る/カクつく(出力のバッファリング)(🔧):
process.stdout.writeで出しているのに1文字ずつに見えないときは、出力がどこかでためこまれているサインです。console.logを断片ごとに使うと毎回改行が入って崩れるので、断片の表示は必ずprocess.stdout.write(改行なし)に。ログをファイルやパイプに流している場合はバッファリングが効いてまとめて出ることがあります(手元のターミナルで直接動かすと素直に流れます)。 - 途中でエラー/接続が切れたとき(🟢):通信は途中で切れることがあります。
runAgentの呼び出しをtry/catchで囲み、失敗したらだまって固まらせず「失敗しました」と一言出す(前作のSSEで[ERROR]を出したのと同じ心がけ)。finalMessage()は途中で失敗すると例外を投げるので、catchで受け止められます。 - 最終回答を二重に表示する(🟢):
streamに替えたあとも、つい昔のprintText(res)を残してしまうと、流して出した同じ文章をもう一度ドンと表示してしまいます。流すようにしたら、最後のprintTextは消す(上のコードではreturnだけにしてあります)。
🤖 AIに頼むなら(Vibe codingのコツ)
🗣 プロンプト例: 「TypeScript + 公式
@anthropic-ai/sdkで作ったエージェントの返事を、ターミナルに1文字ずつ流したい。いまのanthropic.messages.create({...})をanthropic.messages.stream({...})に替えて、stream.on(\"text\", d => process.stdout.write(d))で断片を流し、完成品はconst res = await stream.finalMessage()で受け取って。.on()をnew Promise()で包まないで(finalMessage()を使う)。ループ判定(stop_reason/tool_use)と道具の実行は今のまま変えないで。AIが道具を使う往復では、実行の前に[道具を使います: ${block.name}]を表示して。道具のinputは途中でparseせず、finalMessage()の完成品から読むこと。最後のprintTextは消して(流して表示済みなので二重表示しない)。失敗したらtry/catchで一言出して」
出てきたコードの確認ポイント:
createではなくmessages.stream({...})を使っているか(toolsも渡しているか)- 断片を
stream.on("text", d => process.stdout.write(d))で流しているか(console.logで毎回改行していないか) - 完成品を
await stream.finalMessage()で受け取り、.on()を自前のPromiseで包んでいないか - ループ判定(
stop_reason/tool_use)とexecuteToolは第4章のままか - 道具を使う前に
[道具を使います: ${block.name}]を表示しているか - 道具の
inputを途中でparseしていないか(finalMessage()の完成品から読んでいるか) - 古い
printTextを消して、最終回答を二重表示していないか - ループ回数の上限(暴走防止)が残っているか/途中エラーを
try/catchで出しているか
📝 ことばメモ
- ストリーミング:返事を完成まで待たず、できた断片から順に流すやり方。前作はブラウザへ(SSE)、今回はターミナルへ(
process.stdout.write)流す - デルタ(delta=差分):1回の通知で今回増えたぶんの小さな断片。
stream.on("text", delta => ...)のdeltaがこれ finalMessage():流し終わったあと、完成したMessage(stop_reason/content入り)をまとめて返してくれるSDKの関数。これを使えば自前のnew Promise()は不要process.stdout.write:ターミナルに改行なしで書き出す関数。console.log(毎回改行)と違い、断片をつなげて「1文字ずつ」に見せられる
➡️ 次の章へ
これで、考えている言葉も、道具を使う瞬間も、流しながら見せるエージェントになりました。Claude Code のあの「いま何をしているか分かる」体験を、自分の手で再現できたわけです。
でも、ここまで作ってきて、ある不安が頭をよぎりませんか——往復を重ねるほど、会話履歴(messages)はどんどん長くなります。第4章で見たように、毎ターンこれまでの全部をAIに送り直しているからです。長い作業を続けると、いつかAIが一度に見られる量(コンテキスト)の限界にぶつかり、料金も積み上がります。
次の第14章では、この「長い作業の記憶」の問題に向き合います。コンテキストとは何か、なぜ膨らむのか、古い話を要約して圧縮するにはどうするか、そしてコストとどう付き合うか——を見ていきます。