你说:"帮我把文件里的 moment 替换成 dayjs"
它说:"好的,我来处理" —— 然后你的终端就开始"抽搐"了。
今天咱们就来扒一扒,Claude Code 这类 AI 编程助手在终端里跟你对话时,背后到底发生了什么。别怕,不聊源码,聊人话。
一、早期体验:等得花儿都谢了
如果你用过早期的大模型工具,一定记得这种"抽风式"体验:
- 你敲下一句话,回车
- 白屏几秒 ⏳
- 冒出一段内容
- 又白屏几秒 ⏳
- 工具调用出来了
- 再白屏几秒 ⏳
- 终于又冒出一段内容
整个过程就像在跟一个网络卡顿的异地恋对象打电话——说一句、卡三秒、再说一句、再卡三秒。你盯着屏幕,怀疑人生:"它是不是死机了?还是我网不好?"
为什么会这样?
因为早期的架构是 "要么输出,要么干活" :
- 模型要么在生成 token(输出文字)
- 要么在调用工具(执行函数)
- 两件事不能同时做
所以它得先想好、写完、停下来,才能去调工具;工具执行完、结果回来了,才能继续写。中间那几秒白屏,就是在等——等模型写完,等工具执行完。
二、现在的体验:边想边说边干
现在不一样了。你看到的是:
模型一边打字往外蹦 token,一边顺手就把工具调用给触发了。
这叫 流式架构(Streaming Architecture) 。
它带来的核心变化是:模型不再是"先想完再干",而是"边想边干边汇报" 。
打个比方:
- 早期:你让助理去打印文件,他先站在原地想 10 秒钟"我要怎么走过去",然后走 3 秒钟,到了打印机前再想 5 秒钟"我要按哪个键"……
- 现在:他一边走一边跟你说"我现在走向打印机,快到了,我按下打印键了,正在等出纸"——你全程都有反馈,体感快了好几倍。
体感快 ≠ 实际快,但用户感知的流畅度直接决定了他会不会摔键盘。
三、SSE vs WebSocket:为什么不是 WebSocket?
一提到"服务端主动推消息",很多人第一反应是 WebSocket。毕竟它全双工、双向通信、听起来就很高级。
但 LLM 流式输出,用的却是 SSE(Server-Sent Events) 。为什么?
先看看两者的区别
| 特性 | WebSocket | SSE |
|---|---|---|
| 通信方向 | 全双工(双向) | 单向(服务端 → 客户端) |
| 底层协议 | 独立的 WS 协议 | 就是 HTTP 的一个特殊模式 |
| 连接状态 | 建立后保持,直到主动关闭 | 长连接,但可自动重连 |
| 复杂度 | 较高 | 很低,浏览器原生支持 EventSource |
| 重连机制 | 要自己实现 | 协议自带 retry |
为什么 LLM 场景选 SSE?
关键点在于:LLM 流式输出,本质上是"服务端一个劲地推 token,客户端根本不需要说话"。
- 你发起一个 HTTP 请求:"帮我替换 moment"
- 服务端建立 SSE 长连接
- 服务端:token、token、token…… 一直推
- 客户端:我只负责收,全程闭嘴
既然是单向推送,用 WebSocket 就是杀鸡用牛刀——还得处理心跳、断线重连、协议握手,纯属给自己找麻烦。
而 SSE:
- 就是 HTTP,防火墙友好,不用开新端口
- 协议自带重试机制,断了自动重连
- 浏览器/终端实现都简单
所以,SSE 是 LLM 流式输出的天选之子。
💡 一句话总结:WebSocket 是"电话",SSE 是"广播"。LLM 只需要广播,不需要跟你煲电话粥。
四、Tool Call:模型"挤牙膏"式的工具调用
这是最精彩的部分。
当模型决定调用工具时,它输出的不是一个完整的 JSON,而是一个 token 一个 token 往外蹦的:
text
'{"file_'
'path": "'
'src/uti'
'ls.ts"}'
对,你没看错——连 JSON 都是挤牙膏挤出来的。
这就带来一个非常棘手的问题:你什么时候开始解析这段 JSON?
过早解读:翻车现场
如果你看到 {"file_ 就急着解析,会得到:
text
SyntaxError: Unexpected end of JSON input
或者更惨——调用了错误的工具。比如你以为是 read_file,结果参数还没传完,直接传了个 {"file_ 进去,工具直接崩溃。
过晚解读:效率低下
如果你非要等到整个 JSON 完全闭合(} 出现)才开始处理,那前面那几个 token 的时间就白白浪费了,用户体验又回到了"白屏等几秒"。
怎么办?
成熟的实现会在流式解析和完整解析之间找平衡:
- 用增量 JSON 解析器,边收边攒
- 检测到关键字段(比如
tool_name)就先做预判 - 等参数收完整了再真正执行
这就像你听别人报电话号码:听到 138 就知道是手机号,但你还得等他把后面 8 位报完才能拨出去。
五、灵魂拷问:所有工具都能"边说边调"吗?
不是。
有些工具可以边说边调,有些必须等模型把话说清楚。
举个例子:
- Read 文件:模型说"我看看这个文件",你立刻就能读,不影响
- Edit 文件:模型说"我要把第 3 行的 moment 改成 dayjs"——等等,你确定是第 3 行?确定是 moment 不是 moment.js?确定要改的是这个文件?
如果 Edit 在参数还没说清楚时就执行,轻则改错文件,重则删库跑路。
所以,"边说边调"的前提是:这个工具的执行是幂等的、低风险的、可回滚的。
六、Claude Code 的并发安全判断:谁可以插队?
Claude Code 在工具调度上有一套并发安全规则,非常讲究:
| 工具类型 | 操作性质 | 能否并发 |
|---|---|---|
| Read 文件 | 只读 | ✅ 可以并发 |
| Glob / Grep | 只搜索 | ✅ 可以并发 |
| Edit 文件 | 写入 | ❌ 必须串行 |
Bash ls | 读取类 | ✅ 可以并发 |
Bash rm | 删除类 | ❌ 必须串行 |
为什么这么设计?
- 只读操作:你读你的,我读我的,互不干扰,并发提速
- 写入操作:两个 Edit 同时改同一个文件,谁覆盖谁? 必须排队,一个改完下一个再改
- Bash 命令:
ls和rm天差地别——一个只是看看,一个是真的删东西。Claude Code 会根据命令语义判断,而不是一刀切
这就像图书馆:看书的人可以随便进,但搬书的人得排队,不然书架就乱了。
七、工具结果按什么顺序返回?
按调用顺序返回。
哪怕 Claude Code 内部并发执行了 5 个 Read 工具,结果也会按你调用的顺序一个个吐给你。
为什么要这么"死板"?
- 工程顺序性:后面的工具可能依赖前面的结果
- 可读性:用户看到的日志是连贯的,不是乱的
- 方便调试:出问题时能按时间线回溯
这就像快递分拣:包裹可以同时在多个传送带上跑,但送到你家门口时,必须按订单顺序。
八、完整流程复盘:你敲下回车后发生了什么
来,我们把整个链路串一遍:
text
1. 流式输出
└─ 模型一个 token 一个 token 往外蹦
└─ 你看到文字在终端里"生长"
2. 流式结束,弹出审批弹窗
└─ "我要执行 xxx 工具,是否允许?"
3. 用户确认审批
└─ 你敲的 "yes",其实是发送了一个普通的 HTTP 请求
└─ 不是魔法,就是个 POST
4. 新的 SSE 流开始
└─ 工具执行结果通过新的 SSE 连接推回来
└─ 模型继续边想边说
关键点:整个过程中,客户端和服务端之间可能建立多次 SSE 连接,而不是一个连接从头用到尾。
- 第一次 SSE:模型输出 + 工具调用请求
- 用户审批(普通 HTTP)
- 第二次 SSE:工具执行结果 + 模型继续输出
- ……循环往复
九、总结:一句话记住核心
LLM 流式输出的本质,是服务端用 SSE 一个劲地推 token;Tool Call 是模型"挤牙膏"式地吐 JSON;Claude Code 的并发安全,是"只读随便跑,写入排队走"。
下次你再跟 Claude Code 说"帮我把 moment 替换成 dayjs",它后台忙活的那几秒,你大概能脑补出它在干嘛了:
- 它在边打字边解析 JSON
- 它在判断哪些工具能并发
- 它在按顺序把结果吐给你
- 它在等你敲那个 yes
而你,只需要优雅地喝口咖啡,看它表演。☕
如果这篇文章让你对 AI 编程助手的内部机制有了新的理解,欢迎点赞、收藏、转发三连。毕竟——你敲的每一个 yes,背后都是一整套精密的流式架构在支撑。