第 4 章:一次 Chat 请求——从 JSON 到 token stream¶
一、先看主流程¶
下面这条链是本书最重要的一条:
POST /api/chat
→ ChatHandler
→ parseAndValidateModelRef
→ GetModel
→ modelOptions
→ Scheduler.getRunner
→ chatPrompt / ApplyChatTemplate
→ LlamaServer.Chat 或 Completion
→ parser / thinkingState / structured output
→ streamResponse
→ NDJSON ChatResponse
ChatHandler 的源码很长,因为它同时处理 local、cloud、remote、vision、thinking、tools、structured output、keep-alive 和错误恢复;阅读时不要从头到尾线性扫,先按这条路径定位分支。
二、阶段 1:输入绑定和模型解析¶
handler 首先把 JSON 绑定到 api.ChatRequest,检查 top_logprobs 范围,再解析模型引用。如果 body 缺失,返回 400;如果模型名非法或不存在,返回 400/404,而不是让错误一路掉到 runner。
这个阶段的价值是早失败:模型名、请求格式和参数错误不应触发显存分配或启动子进程。
三、阶段 2:模型选项合并¶
Server.modelOptionsWithEmbeddingBatchDefault 的合并顺序是:
api.DefaultOptions()
↓
server.defaultNumCtx(若默认 context 为空)
↓
Model.Options(Modelfile 持久配置)
↓
Request.Options(本次请求覆盖)
↓
embedding batch / draft model 特殊默认值
注意它还需要区分“用户没有设置”与“默认值恰好相同”。例如 draft_num_predict 是否显式出现,会影响无 draft model 时的处理。这个细节说明 options 合并不是简单的 map 覆盖,而是带有来源语义。
四、阶段 3:Scheduler 给出 runner¶
scheduleRunner 做四类工作:
- 检查模型存在。
- 检查模型能力是否满足请求,例如 embedding、vision、thinking。
- 推断
num_ctx、num_batch是否自动计算。 - 调用
s.sched.getRunner,等待 runner channel 或错误 channel。
runnerCh, errCh := s.sched.getRunner(...)
select {
case runner = <-runnerCh:
case err = <-errCh:
return nil, nil, nil, err
}
handler 因此只得到一个满足接口的 llm.LlamaServer,不需要知道 runner 是刚加载、从缓存复用,还是经过 OOM 降低了上下文。
五、阶段 4:消息如何变成 prompt¶
Ollama 同时支持两种主要渲染策略:
Go template 路径¶
server/prompt.go 使用模型携带的 template 和 ollamatemplate 逻辑,将 system/user/assistant/tool 消息渲染为文本 prompt;同时处理图像标签和媒体索引。
原生 Jinja/chat template 路径¶
若模型和 runner 支持,server 可以把结构化 ChatRequest 交给 llama-server 的 chat template 处理。llm.ChatRequest 保留 messages、tools、format、think 等结构,这条路径更接近模型原始模板。
为什么两条路并存¶
模型生态不是同质的:有些模型只在 GGUF metadata 中提供模板,有些 parser/工具调用依赖 Go 侧控制,有些模型的 Jinja 行为更完整。server 通过 chatModeForModel、shouldUseGoTemplate 等函数做选择,而不是强行所有模型都走同一个模板。
六、阶段 5:流式响应不是简单转发¶
runner 返回的每个 chunk 可能包含:
- 文本 content
- thinking 内容
- tool call
- logprobs
- done 状态
- token 与耗时统计
当请求带 tools 时,server 可能要暂存文本,等待内置 parser 判断是不是工具调用;当请求带 structured output 时,还要等模型输出足够内容,再切换到 grammar/格式化路径。代码中可以看到 thinkingState、toolParser、structuredOutputsState 三个状态维度。
因此响应处理是一个小型状态机:
stateDiagram-v2
[*] --> Buffering
Buffering --> Thinking: 检测到 thinking
Thinking --> Content: thinking 结束
Buffering --> ToolParsing: 请求包含 tools
ToolParsing --> ToolCall: parser 识别调用
ToolParsing --> Content: 识别为普通文本
Content --> Structured: format 需要结构化输出
Content --> Done: runner.Done
ToolCall --> Done: 发出完整 call
七、阶段 6:结束与卸载¶
如果请求是空 messages + keep_alive=0,handler 会把它解释成卸载请求,直接调用 expireRunner 并返回 done_reason: unload。这是一种很实用的 API 约定:客户端可以通过普通 chat 入口释放模型,而不需要额外的管理协议。
八、一次请求的失败路径¶
| 阶段 | 典型错误 | 处理 |
|---|---|---|
| 绑定 | JSON 无效 | 400 |
| 模型引用 | 名称非法/不存在 | 400/404 |
| capability | 模型不支持 vision/embedding | 业务错误 |
| Scheduler | 队列满、加载失败、显存不足 | 流式错误或 HTTP 错误 |
| template | 模板执行失败 | 500/流式错误 |
| runner | 子进程退出或连接断开 | done/error 与日志 |
| client | 取消请求 | context cancellation,保留已收到内容 |
九、设计取舍¶
这条链路把“决定”和“执行”分开:handler 决定请求意图和协议表现,Scheduler 决定资源,runner 决定计算,parser 决定如何解释 token。代价是调用链变长、状态更多;收益是新增模型、协议或硬件时不必改动所有层。