跳转至

第 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 做四类工作:

  1. 检查模型存在。
  2. 检查模型能力是否满足请求,例如 embedding、vision、thinking。
  3. 推断 num_ctxnum_batch 是否自动计算。
  4. 调用 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 通过 chatModeForModelshouldUseGoTemplate 等函数做选择,而不是强行所有模型都走同一个模板。

六、阶段 5:流式响应不是简单转发

runner 返回的每个 chunk 可能包含:

  • 文本 content
  • thinking 内容
  • tool call
  • logprobs
  • done 状态
  • token 与耗时统计

当请求带 tools 时,server 可能要暂存文本,等待内置 parser 判断是不是工具调用;当请求带 structured output 时,还要等模型输出足够内容,再切换到 grammar/格式化路径。代码中可以看到 thinkingStatetoolParserstructuredOutputsState 三个状态维度。

因此响应处理是一个小型状态机:

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。代价是调用链变长、状态更多;收益是新增模型、协议或硬件时不必改动所有层。