聊天界面让人与大型语言模型的交互看起来很简单: 输入消息,按下 Enter,然后等待回答出现。在这次交互背后, 运行着一条由浏览器、应用服务器、数据库、策略系统、模型网关、推理工作进程, 以及工具或检索服务共同参与的分布式请求流水线。
在已有会话中,新文本只是模型输入的一部分。应用必须重建足够的会话内容, 将其与隐藏指令和其他上下文结合,在有限的上下文窗口中容纳全部内容,运行推理, 流式传输结果,并持久化新的状态。
典型路径如下:
具体产品可能会合并、重新排序或省略其中一些阶段。模型 API 也可能位于应用内部, 而不是作为独立服务存在。一般原则保持不变:Web 应用拥有会话,而普通的语言模型调用 则将有界的输入 token 序列转换为输出 token 序列。
发送消息之前 #
用户输入时,浏览器通常会更新文本区域,并将草稿保存在组件状态中。 它还可能:
- 调整编辑器大小;
- 将草稿保存到本地存储;
- 在发送消息前上传并扫描附件;
- 识别斜杠命令或提及;
- 估算输入是否超过产品限制;以及
- 启用或禁用发送按钮。
这项工作通常不涉及语言模型。自动补全、拼写检查和输入法编辑器可以生成自己的浏览器或 操作系统事件,但聊天应用仍然会接收最终文本。
按下 Enter、点击 Send 或点按移动端的等效控件后,会触发送处理程序。 由于编辑器可能立即被清空以便输入下一条消息,处理程序会捕获当前草稿的不可变版本。 它还会创建用于关联乐观 UI、网络请求和最终数据库记录的标识符。
常见标识符包括:
| 标识符 | 用途 |
|---|---|
| Conversation ID | 选择现有会话 |
| Message ID | 标识这条用户消息 |
| Parent message ID | 选择一个分支或前置消息 |
| Request or turn ID | 关联一次生成尝试 |
| Idempotency key | 防止重试导致消息被创建两次 |
| Client trace ID | 连接浏览器和服务器遥测 |
浏览器通常会立即将用户消息添加到消息记录中。 这是一种乐观更新:在服务器确认之前,UI 就显示预期状态。 如果提交失败,可以将消息标记为失败并提供重试,而不是让它消失。
提交这一轮交互 #
浏览器发送经过身份验证的 HTTPS 请求,其中包含会话标识符、新消息、附件引用、所选模型和产品特定选项。
它可能使用常规的 POST 请求:
POST /api/conversations/conv_123/messages HTTP/1.1
Authorization: Bearer ******
Content-Type: application/json
Idempotency-Key: turn_789
{
"message_id": "msg_456",
"parent_id": "msg_455",
"content": [
{
"type": "text",
"text": "Compare that approach with optimistic locking."
}
],
"model": "example-chat-model"
}这是应用自己的 API,不一定是模型提供商的 API。 后端之后可能会将这一轮转换为 OpenAI 兼容请求、提供商专用协议, 或直接调用推理运行时。
请求会经过内容分发网络、Web 应用防火墙、负载均衡器和 API 网关等常规 Web 基础设施。 这些层可以终止 TLS、拒绝过大的请求体、实施请求速率限制,或将请求路由到聊天服务。
聊天服务会对用户进行身份验证,并授权其访问会话。仅知道会话 ID 不得足以读取或扩展其他用户的会话。 它还会验证父消息、模型选择、功能标志、附件所有权和账户配额。
记录用户消息 #
应用通常会在开始高成本的生成之前持久化用户消息。 一种简化的数据模型可能包含:
先持久化消息后,即使浏览器断开连接或推理工作进程失败,这一轮交互仍然可以恢复。
消息或生成状态可以经历 pending、running、completed、failed 和 cancelled 等状态。
已有会话不一定只是一个简单列表。编辑旧消息、重新生成回答,或在多个候选回答之间进行选择, 都会形成一棵树:
parent_id 告诉后端当前激活的是哪条路径。当用户发送 F 时,上下文通常应包含
A, B, C, D, F,而不应包含兄弟回答 E 或其后代 G。
有些系统会将分支复制到各自独立的线性会话中。无论采用哪种表示方式,都必须在调用模型前解析为一条有序历史记录。
重建会话 #
模型并不会天然知道浏览器正在显示一个已有会话。大多数聊天推理端点都是无状态的: 每次调用都必须提供该次调用所需的上下文。会话 ID 对应用有用,但除非有状态模型服务显式实现相关机制, 否则它对模型没有意义。
后端加载当前激活的消息路径,并构建类似下面的逻辑输入:
system: You are a helpful assistant.
developer: Follow the application's formatting and tool-use rules.
user: How does optimistic concurrency control work?
assistant: It detects conflicts rather than holding a lock...
user: Compare that approach with optimistic locking.可见的消息记录不一定就是模型接收的确切输入。后端可能添加:
- system 和 developer 指令;
- 账户、工作区或自定义指令;
- 当前日期和区域设置;
- 模型特定的控制 token;
- 工具定义和工具结果;
- 检索到的文档或 Web 结果;
- 附件内容或图像引用;
- 较旧消息的摘要;
- 输出格式约束;以及
- 安全或产品策略指令。
相反,一些可见数据可能不会被发送。仅供 UI 使用的状态消息、错误横幅、引用、反应按钮和隐藏分支, 属于应用状态,而不是会话上下文。
适配上下文窗口 #
每个模型都有以 token 计量的最大上下文长度。输入 token、生成的输出 token,以及有时存在的模型特定推理 token, 会共同占用这一预算。数据库中的完整历史不能无限追加。
上下文管理器首先使用与所选模型关联的 tokenizer 估算或计算 token 数量, 然后为回答预留空间:
如果输入过大,应用可以:
- 删除最早的会话轮次;
- 保留最初的指令和最近的轮次;
- 总结较旧的轮次并加入摘要;
- 只检索与新消息相关的历史;
- 减少检索文档或工具描述;
- 拒绝请求并要求用户缩短输入;或
- 将这一轮路由到上下文窗口更大的模型。
简单地删除旧消息可能会破坏“将它与第一个设计进行比较”这类引用。 摘要可以节省 token,但可能丢失精确措辞、代码、数字、不确定性或归属信息。 因此,上下文管理是一项应用策略,而不仅仅是 tokenizer 操作。
附件需要独立处理。文本文件可能被解析、切分、建立索引,或直接插入。 图像可能成为多模态模型的编码图像输入。大型文档集合通常会先执行搜索, 从而只将相关摘录放入模型上下文。
内容审核、限制与调度 #
推理前,一个或多个服务可能会评估请求。根据产品不同,这些检查可以包括:
- 滥用和内容分类;
- 针对外部内容的提示注入防御;
- 附件恶意软件扫描;
- 每用户和每组织配额;
- token 和支出限制;
- 区域数据处理限制;
- 模型可用性和使用资格;以及
- 并发或速率限制。
策略可以在提示构造前、提示构造后、输出流式传输期间,或这三个阶段全部执行。 提前拒绝可以避免浪费推理能力,而输出检查则可以捕获直到生成时才出现的内容。
通过检查的请求会进入调度器或模型网关。调度器根据可用性、延迟、成本、硬件容量和租户策略, 选择提供商、区域、模型部署和推理工作进程。托管产品可能会将同一会话中的不同轮次路由到不同的物理机器。
调度器可以批处理无关用户的请求,使 GPU 能够高效处理它们。连续批处理会在其他序列完成时加入或移除序列, 而不是等待整个固定批次完成。不过,仍然必须保持隔离,防止一个请求观察到另一个请求的 token 或数据。
将消息转换为模型输入 #
聊天服务通常将一轮交互表示为结构化数据:
[
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Why is the sky blue?"
}
]Transformer 不能直接使用 JSON 对象、角色名称或 JavaScript 字符串。 推理前,服务必须将这一结构转换为特定模型所需的整数 token 序列:
聊天模板 #
聊天模板定义如何序列化角色、内容部分、工具调用和轮次边界。 一个简化的类 Jinja 模板可能如下:
{{ bos_token }}
{% for message in messages %}
<|{{ message.role }}|>
{{ message.content }}
<|end|>
{% endfor %}
{% if add_generation_prompt %}
<|assistant|>
{% endif %}将其应用于上述 JSON,可能得到:
<|begin|><|system|>
You are a helpful assistant.
<|end|><|user|>
Why is the sky blue?
<|end|><|assistant|>这是用于说明的示例,并非某个具体商业模型的模板。真实模板在特殊 token、空白、角色顺序、多模态部分、 工具调用编码方式以及生成提示位置方面都可能不同。有些模板使用显式角色 token,另一些使用标头或指令分隔符。 tokenizer 必须识别模板使用的所有特殊 token。
末尾的 assistant 标记告诉模型,下一个 token 应属于 assistant。 解码期间输出的结束标记可以告诉运行时这一轮已经完成。 即使所有可见文本都存在,使用错误模板也会显著降低行为质量,因为模型经过训练后, 会将自身的分隔符与特定角色和边界关联起来。
聊天模板与供应商响应模式 #
聊天模板本身无法解释为什么同一供应商的每个响应都有相同的 JSON 结构。 这些结构位于不同层次:
供应商的 API 契约定义了消息角色、内容、结束原因、使用量、引用或工具调用等稳定字段。 网关可以在将请求路由到多个内部模板和 tokenizer 不同的模型版本时,仍然暴露相同的契约。 它会将通用请求转换为所选模型的格式,再将结果封装回通用响应格式。
以下几种模式很容易混淆:
| 模式 | 约束内容 | 由谁执行 |
|---|---|---|
| API request schema | 允许的请求字段、类型和选项 | API 网关 |
| API response schema | 响应信封、内容部分、使用量和错误 | 供应商后端 |
| Tool input schema | 模型可以请求工具使用的参数 | 模型运行时和宿主 |
| Structured-output schema | 要求 assistant 生成的 JSON 内容 | 解码器、验证器或重试循环 |
| Browser event schema | 开始、增量、引用、状态和结束事件 | 聊天后端和 Web UI |
这些模式通常使用 JSON Schema 或类似 JSON Schema 的供应商特定子集描述。 例如,应用可以要求 assistant 内容匹配以下结构:
{
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": ["answer", "confidence"],
"additionalProperties": false
}支持范围不一定等同于完整发布的 JSON Schema 草案。提供商可能只接受部分关键字,
要求每个属性都列在 required 中,限制递归或模式大小,或拒绝不受支持的组合。
应用的 SDK 可以在发送前验证请求,但服务器仍然是最终权威。
API 响应信封是合法 JSON,并不意味着 assistant 的文本也是 JSON。
普通响应可以有模式定义的外层对象,而其 content 字段包含任意 Markdown。
相反,结构化输出模式会将该内容约束为对象或其他 JSON 值。
工具调用是另一个结构化通道:模型提出符合工具模式的参数,但宿主仍必须在执行前解析并验证它们。
Web UI 还可以在上层添加另一个专有协议。ChatGPT、Gemini 和 Claude 不必使用与其公开 API 相同的内部请求格式, 它们的浏览器事件流也可能包含 UI 专用数据。单个产品的响应仍然看起来一致,是因为其前端和后端共同遵循一种应用模式。 原始 Transformer 产生的是 token 概率,而不是供应商响应对象。
结构化输出也有多种实现方式。模型可能生成普通文本,再由网关解析;生成特殊的工具调用 token; 或在解码期间受约束,以匹配某种 JSON 语法。无论采用哪种方式,网关都会在返回客户端前验证并封装结果。
供应商回答为何具有可识别的形式 #
JSON 响应模式解释了机器可读的信封,但无法解释 assistant 消息内部反复出现的措辞模式。 某个产品的回答可能总是使用先总结后列举的结构、粗体标签、注意事项,或以“如果你愿意,我还可以……”结尾。 这种风格可能来自多个层次:
- 预训练、指令微调和偏好优化中学习到的行为;
- 设定语气和格式的隐藏 system 或 developer 指令;
- 产品特定的自定义指令或会话记忆;
- 提示中提供的示例;
- 详细程度和 Markdown 使用方式等模型默认行为;以及
- 添加引用、通知或其他 UI 元素的后处理。
聊天模板提供角色和边界标记,但通常不包含每个回答的文字结构蓝图。 模板实际上可以表示“assistant 输出从这里开始”;system 指令可以表示“以提供有用的下一步结束”; 而训练可以让模型即使在当前提示中没有这句话,也倾向于表现出这种行为。
重复出现的措辞不是协议保证。ChatGPT 不必在每个回答结尾都提供总结建议, Gemini 或 Claude 也没有强制的文字结尾。措辞可能随所选模型、system 指令、用户请求、会话历史、 产品实验和采样方式而变化。需要保证结构的应用应请求模式约束输出,或验证显式格式, 而不是解析供应商惯用的散文表达。
tokenizer 的实现方式 #
tokenizer 由算法和模型特定数据组成。常见算法包括 byte-pair encoding(BPE)、WordPiece、Unigram/SentencePiece 以及字节级变体。数据通常定义:
- 将 token 字节字符串或片段映射到整数 ID 的词表;
- 合并规则、分数或分词模型;
- 规范化和预分词规则;
- 特殊 token 及其 ID;以及
- 添加模型控制 token 的后处理规则。
它不一定是一个单独的可执行二进制文件。在浏览器或 SDK 中,它可以是 JavaScript、Python、Rust、Java 或原生库。 推理服务器通常会将快速的 Rust 或 C++ 实现链接到服务器进程中。另一种架构在 API 工作进程或专用服务中执行分词, 再将 token ID 发送给 GPU 工作进程。命令行 tokenizer 可行,但每个请求都启动一个进程会增加可避免的开销。
开放模型仓库通常会将 tokenizer 数据与模型放在一起,使用如下文件:
| 文件 | 典型内容 |
|---|---|
tokenizer.json | 完整序列化的 tokenizer 流水线 |
vocab.json and merges.txt | BPE 词表和合并规则 |
tokenizer.model | SentencePiece 模型 |
tokenizer_config.json | 运行时选项和聊天模板元数据 |
special_tokens_map.json | 命名特殊 token 的分配 |
名称和布局会因框架而异。托管供应商可能将这些制品存储在内部模型注册表、对象存储、 容器镜像或本地工作进程缓存中。部署会将 tokenizer 修订版本固定到模型修订版本。 这种配对属于模型契约的一部分:如果保留相同权重却更改映射,整数 ID 就会引用错误的学习嵌入。
应用和模型服务还必须在计数 token 以执行限制或计费时保持一致。 使用相似但不同 tokenizer 进行的客户端估算,可能与提供商实际处理的 token 数量不同。
一个具体的 token ID 示例 #
对于公开的 GPT-2 字节级 BPE tokenizer,下面的精确文本:
Hello, world!会被编码为:
text pieces: ["Hello", ",", " world", "!"]
token IDs: [15496, 11, 995, 0]开头的空格属于 " world" token。这也是 token 边界不等同于单词边界的原因之一。
另一个 tokenizer 可能为同一文本分配完全不同的序列。大小写和空格也会改变结果:
"Hello" != "hello"
"world" != " world"这些不等式表示“可能会被分词为不同的 ID”,并不表示这些单词必须始终对应单个 token。 代码、不常见的名称、拼写错误以及某些书写系统通常会被拆分为更多片段。 现代的字节感知 tokenizer 可以表示任意输入,即使不存在方便的完整单词 token。
真实的聊天请求还会包含用于序列化 system 消息、角色分隔符、轮次分隔符以及 assistant 生成标记的额外 ID。 token ID 只有结合指定 tokenizer 和修订版本才有意义,因此文章或日志绝不应在未标明该映射的情况下展示 ID 序列。
图像和音频通常不会经过文本 tokenizer。独立的编码器或 tokenizer 会将它们转换为图像块、音频特征或模态特定 token, 再与文本表示结合。
拼写错误会怎样 #
tokenizer 通常不会纠正拼写。它会忠实地编码接收到的字节或字符,因此拼写错误可能变成不常见的 token 或多个更小的 token。 拼写错误的单词仍然可以通过子词或字节片段表示。语言模型可能根据上下文推断出目标词,但那是学习到的预测,而不是 tokenizer 的纠错, 并且不具有保证性。
拼写错误可能在其他阶段被修改:
- 浏览器的拼写检查器可以在提交前修改编辑器内容;
- 应用可以运行显式的拼写修正或查询重写步骤;
- 检索系统可以搜索拼写变体或使用语义嵌入;
- 摄取流水线可以对文档应用 OCR 修正或规范化;以及
- 可以指示模型静默解释可能的错误,或明确纠正这些错误。
即使应用为搜索创建规范化副本,也应保留原始用户消息和源文档。 静默重写可能改变名称、源代码、标识符、引用文本、医学术语或用户实际表达的含义。 检索系统通常同时保留原始字段和规范化字段:规范化形式改善匹配,原始形式用于展示给用户并进行准确引用。 任何生成的纠正都应保留来源;当歧义很重要时,应请求澄清,而不是臆造替换内容。
Prefill:读取输入 #
对于典型的 decoder-only 聊天模型,prefill 在聊天模板处理和分词之后开始。 它的主要输入是完整的输入 token ID 序列:
token IDs: [1, 32001, 887, 526, 263, 8444, 20255, 13, 32002, ...]
positions: [0, 1, 2, 3, 4, 5, 6, 7, 8, ...]运行时还会提供批处理元数据、序列长度、位置信息,以及因果注意力掩码或等价的优化元数据。 在批次中,较短序列可能会被填充或打包。上述确切 token ID 仅用于说明,因为 ID 只有对某一 tokenizer 修订版本才有意义。
模型首先从学习到的嵌入表中查找每个 token ID,生成向量而不是单词:
其中:
S是序列长度,即输入 token 位置的数量;V是词表大小,即可以索引该表的 token ID 数量;以及H是模型的隐藏维度,即每个 token 嵌入中学习到的标量分量数量。
因此,token ID 输入是形状为 [S] 的一维整数向量。嵌入表是一个学习到的浮点矩阵,
每个词表条目对应一行。查找全部 S 个 ID 会选择 S 行,并生成形状为 [S, H] 的浮点矩阵。
加入批次维度 B 后,相应形状为 [B, S]、[V, H] 和 [B, S, H];同一个嵌入表在整个批次中共享。
图中所示数据类型是常见示例,而非强制要求。使用量化嵌入表的选中行时,通常会将其反量化或转换为模型的计算数据类型。
位置关系会被添加或应用,使相同 token 位于不同位置时可以表现不同。在上述加法设计中,E 和 P 具有相同形状,
并按分量相加。P 不会作为额外特征列进行拼接:拼接会产生 [S, 2H],而数值相加会保留 [S, H]。
例如,假设 token ID 995 出现在位置 2 和 9。嵌入查找最初会在两个位置都返回同一个学习向量 E[995]。
使用学习到的绝对位置嵌入的模型,可以将以下不同向量传递给第一层:
position 2: E[995] + P[2]
position 9: E[995] + P[9]这也能区分 "dog bites man" 和 "man bites dog"。两个序列包含相同的三个 token 嵌入,
但这些嵌入位于不同位置。没有位置信息时,self-attention 就无法直接表示这种顺序。
并非所有模型都会字面上加上位置向量:
- 对于旋转位置嵌入,即 RoPE,运行时会根据位置旋转每个 query 和 key 的分量。 基础 token 嵌入可以保持不变,而注意力计算会对绝对位置和相对位置敏感。
- 其他架构使用相对位置偏置或相关机制。
带有位置信息的向量会经过每个 Transformer 层。在一个简化的 decoder 层中:
因果规则允许位置 i 的表示只关注直到 i 的位置,而不能关注后续输入位置。
- 尽管这种依赖关系具有因果性,GPU 仍可以通过大规模并行矩阵运算计算所有输入位置的 prefill 表示。
- 这与解码不同:解码必须先得到新选出的 token,才能开始下一步。
对于每一层和每个输入位置,注意力会计算一个 key 向量和一个 value 向量。 运行时将这些向量保存在 KV cache 中:
推理完成后,通常不会保留 prefill 产生的所有临时激活值。 KV cache 是重要的持久结果,因为后续每个解码步骤都可以关注已有序列,而不必为所有较早 token 重新计算 key 和 value。
在最后一层,最后一个输入位置的隐藏状态会经过语言模型输出头投影为词表中每个 token 的一个分数,即 logit。 经过惩罚项、约束和采样变换处理后,这些 logit 定义了第一个生成 token 的候选项。 因此,prefill 最终产生两个结果:
- 输入前缀的逐层 KV cache;
- 用于选择第一个输出 token 的 logit。
选择第一个 token 后,运行时进入解码阶段。每个解码步骤都会将一个位置的 key 和 value 添加到现有 cache, 并为后续 token 生成 logit。encoder-decoder 模型以不同方式划分这项工作,但现代文本聊天模型大多采用这里描述的 decoder-only 模式。
prefill 的工作量会随着输入长度增长,并且通常计算密集。 因此,即使回答很短,较长的已有会话也会增加回答开始显示前的延迟。
前缀缓存示例 #
假设应用发送一份 10,000-token 的手册,随后发送一个问题:
在请求 1 中,服务器会计算整个序列的 KV 条目。前缀缓存可以保留稳定的 system 指令和手册对应的 KV 块。 后续请求可能是:
如果序列化字节产生相同的前导 token ID,并且部署使用相同的模型、tokenizer、模板和相关推理设置, 运行时就可以挂接已缓存的 KV 块,只对新的后缀执行 prefill。模型仍然会关注手册; 服务器只是复用了已经计算出的 key 和 value,并没有省略文档。
生产缓存通常会将前缀划分为固定大小的 token 块,并将每个块与模型和租户元数据一起进行哈希。 因此,可复用长度可能结束于最后一个匹配块的边界,而不是最后一个匹配 token。
当开头附近的 token 发生变化时,缓存通常无法提供帮助:
第一个差异之后的所有 token 都具有不同的注意力上下文,因此不能简单地拼接其缓存状态。 同样,即使可见问题没有改变,修改空白、工具定义、隐藏指令、附件顺序或聊天模板,也可能改变 token 前缀。
前缀缓存不同于缓存最终回答。它复用了内部注意力状态,但仍然执行解码,因此采样得到的回答可能不同。 它也不同于普通解码期间使用的每请求 KV cache:前缀缓存跨请求保存可复用的 KV 状态, 而普通 KV cache 只需在一次活动生成期间存在。
提供商会规定容量、过期时间、最小前缀长度、隔离和计费规则。有些提供商自动填充缓存, 另一些则要求显式的缓存对象或标记。仅仅复用会话 ID 并不能保证命中; 缓存键必须防止一个租户获取另一个租户的私有前缀状态。
解码:生成回答 #
prefill 之后,模型会预测下一个 token 的概率分布。解码策略选择一个 token,将其添加到序列中,然后运行另一个模型步骤:
该循环会持续到模型:
- 输出停止 token 或停止序列;
- 达到输出 token 上限;
- 请求使用工具;
- 被策略系统停止;
- 遇到错误;或
- 收到取消信号。
选择可以是贪心选择,也可以使用 temperature、top-p 等采样控制。 产品还可能应用重复惩罚、约束解码、语法强制、推测解码或提供商特定的推理机制。
模型通常不会在一次操作中生成整段文字,而是逐步生成 token。 一个 token 可以是完整单词、单词的一部分、标点或空白。 由于一个字符可能跨越 token 字节边界,服务器必须谨慎地将 token ID 解码回文本。
流式传输回浏览器 #
推理工作进程会向模型网关发出 token 或文本增量。 网关将其转换为应用事件,然后通过聊天服务转发给浏览器。
常见传输方式包括:
- 流式
fetch()响应; - Server-Sent Events(SSE);
- WebSocket 消息;或
- 后端消费后通过另一种协议中继的提供商流。
类似 SSE 的流可能包含:
event: message_start
data: {"message_id":"msg_457"}
event: content_delta
data: {"text":"Optimistic locking"}
event: content_delta
data: {"text":" is a common form"}
event: content_delta
data: {"text":" of optimistic concurrency control."}
event: message_end
data: {"finish_reason":"stop"}传输内容可能远不止文本,还包括推理摘要、引用、工具调用、使用量计数器、警告或心跳事件。 序列号有助于在重新连接后检测重复或缺失事件。
流式传输可以让用户在生成结束前开始阅读,从而改善感知延迟。 但它不一定会减少总生成时间。重要的延迟指标包括:
| 指标 | 含义 |
|---|---|
| Request latency | 后端接受这一轮交互前所需的时间 |
| Queue time | 等待推理容量的时间 |
| Time to first token | 从提交到第一个输出到达的时间 |
| Inter-token latency | 连续输出 token 之间的延迟 |
| Total latency | 生成完成前所需的时间 |
即使解码已经开始,多个缓冲层也可能延迟可见输出:推理服务器、反向代理、压缩层、 内容过滤器和浏览器流解析器。流式端点通常会禁用代理缓冲,并有意刷新小型事件。
渲染部分回答 #
浏览器读取事件并更新待处理的 assistant 消息。 每个微小 token 都更新一次文档对象模型可能代价很高,因此 UI 通常会累积增量, 并按动画帧或较短的定时器间隔进行渲染。
Markdown 会带来另一个问题:部分流经常不是有效的 Markdown。 代码围栏、链接、表格或强调标记可能还没有闭合。 渲染器必须容忍不完整输入,并随着更多文本到达而重新渲染。 语法高亮和数学排版可能会延迟或限流,因为反复处理整个回答会造成明显卡顿。
浏览器应将生成的 HTML 视为不可信内容。 Markdown 渲染器必须转义或清理危险输出,防止模型生成的脚本、事件处理器属性或不安全 URL 在页面中执行。
自动滚动应当是有条件的。 当用户已经接近底部时,视图可以跟随回答; 但不应反复把用户从正在阅读的旧消息位置拉走。
工具调用与多步骤交互 #
响应不一定是面向用户的散文。如果提示中提供了工具,模型可能会输出结构化请求:
{
"name": "get_order",
"arguments": {
"order_id": "42"
}
}执行工具的是应用,而不是模型。典型的智能体循环如下:
因此,一轮可见的用户交互可能包含多次模型调用,以及对数据库、搜索引擎、代码解释器或远程 API 的多次调用。 UI 可能显示“正在搜索”“正在读取文档”或“等待批准”等中间状态。
工具结果会消耗上下文 token,并且自身也可能不可信。 宿主必须执行权限、超时、结果大小限制和副作用控制。 破坏性操作不会仅仅因为模型请求它们就变得安全。
检索遵循类似模式。应用可以将用户问题重写为搜索查询,检索候选文档,对其重新排序, 将选定摘录插入上下文,然后调用回答模型。 有些系统在第一次模型调用前进行检索;另一些系统允许模型在这一轮交互中选择搜索工具。
完成并持久化回答 #
解码完成后,模型服务会发出结束原因和最终元数据。 应用将所有增量组装成规范的 assistant 消息,并记录:
- 生成的内容和结构化内容部分;
- 模型和模型版本标识符;
- 输入、输出、缓存或推理 token 使用量;
- 工具调用和工具结果;
- 引用或来源引用;
- 结束原因;
- 延迟和跟踪标识符;以及
- 策略决策或错误详情。
最终数据库写入应当是幂等的。 重试和重复的流事件不得创建多个 assistant 消息。 有些系统会在生成期间持久化部分检查点,以便仍然显示被中断的回答; 另一些系统只保存最终组装的消息。
服务器只有在确定结果后才会发送终止事件。 浏览器随后将 assistant 消息标记为完成,停止活动指示器,启用复制或重新生成等操作, 并将乐观 ID 与服务器发出的 ID 进行协调。
回答显示后,后台工作仍可能继续。 应用可能会生成会话标题、更新搜索索引、计算安全分析、提取长期记忆、记录计费或输出质量指标。 除非这些任务的结果是这一轮交互所必需的,否则不应延长用户可见的延迟。
取消、重试和重新生成 #
按下 Stop 通常会取消生成,而不是撤销已经生成的文本。 浏览器发送取消请求或关闭流,后端将取消信号传播给排队任务、工具调用和推理运行时。 提供商可能已经生成了正在传输中的额外 token,计费语义也各不相同。
部分回答可以保留,并使用 cancelled 作为结束原因。
如果它会出现在后续上下文中,应用应将其与正常结束的回答区分开。
网络断开不等同于取消。 浏览器可能消失,但服务器端生成仍在继续。 应用可以采用以下策略之一:
- 客户端断开后立即取消工作;
- 让生成完成并持久化回答;
- 保留可恢复的事件流以便重新连接;或
- 仅在短暂的断开宽限期内继续生成。
重试提交时应复用幂等键,以免不确定的网络结果导致用户消息被追加两次。
Regenerate 通常会在同一条用户消息下创建新的 assistant 兄弟节点。 它可以复用同一个提示,同时改变随机采样、模型或选项。 它不必覆盖之前的回答。 Edit and resubmit 会从被编辑消息的父节点创建一个分支,因为所有后续消息都是基于原始措辞生成的。
不同层次的故障 #
UI 不应将所有故障都简化为“模型失败”。 不同阶段具有不同的恢复行为:
| 阶段 | 示例 | 典型处理 |
|---|---|---|
| Browser | 离线或上传中断 | 保留草稿并重试 |
| Gateway | 身份验证或速率限制 | 重新认证或等待 |
| Conversation service | 分支无效或权限不足 | 不执行推理,直接拒绝 |
| Context assembly | 输入超过模型限制 | 安全裁剪或要求缩短输入 |
| Scheduler | 没有模型容量 | 排队、重试或改路由 |
| Inference | 工作进程崩溃或超时 | 将生成标记为失败,并谨慎重试 |
| Tool | 参数无效或远程错误 | 向循环返回明确的工具错误 |
| Stream | 回答中途连接断开 | 恢复、保留部分输出或重新连接 |
| Persistence | 最终写入冲突 | 使用稳定 ID 和幂等性进行协调 |
只有当操作具有幂等性或其影响已知时,自动重试才是安全的。 重试文本生成可能得到不同的回答。 重试发送邮件、下订单或删除文件的工具可能重复真实世界的副作用。
错误应与 assistant 散文分开表示。 生成的“发生了错误”这句话不等同于传输或工具故障,也不应控制应用状态。
隐私、安全与可观测性 #
请求包含的信息可能多于用户刚输入的文本。 它可能包括之前的消息、上传文件、检索到的私有文档、工具输出和隐藏指令。 路径上的每个服务都应只接收其所需的数据。
重要控制措施包括:
- 传输中和静态存储中的加密;
- 对每次会话和附件查找执行授权;
- 在存储、缓存、队列、日志和推理中隔离租户;
- 从遥测中脱敏或排除敏感提示数据;
- 保留和删除策略;
- 可审计的工具执行与用户批准;
- 浏览器中的输出清理;以及
- 防止检索内容中嵌入的指令影响系统。
可观测性同样重要。 分布式追踪可以将浏览器中的一轮交互连接到网关、检索、工具和推理 span。 有用指标包括队列时间、首 token 时间、每秒 token 数、上下文大小、缓存命中率、 结束原因、取消率和故障阶段。 记录完整提示便于调试,但存在风险;元数据、哈希、抽样后的脱敏内容以及受访问控制的诊断存储, 是更安全的默认选择。
基本心智模型 #
理解已有的 LLM 聊天的最佳方式,是将其看作一个围绕一个或多个有界模型调用构建的有状态应用:
会话表面上的连续性,是由应用重建并重新发送上下文产生的。 类似打字的效果,是由流式传输增量解码产生的。 搜索、计算或执行操作的能力,是由应用围绕模型控制的循环产生的。
这种分离解释了许多看似意外的行为:旧细节在超出所选上下文后会消失, 长会话需要更长时间才能开始,重新生成会产生不同分支,被停止的回答可能仍然部分可见, 并且除非周围的应用显式提供相关信息或能力,否则模型无法访问会话、数据库或网站。
评论
在 GitHub 上阅读或评论(发表评论需要登录)