🎁 新人 免费注册,送 10 次调用,最高 $1,免绑卡。
构建 LLM 聊天机器人:流式输出、上下文压缩与记忆

构建 LLM 聊天机器人:流式输出、上下文压缩与记忆

分步构建聊天机器人:实现模型选择器,让“停止”操作真正传递至模型提供商并中止上游请求;实测上下文预算及其边界,将超长对话压缩为可复用记忆,并通过网关接入网页搜索,完整覆盖请求控制、上下文管理与外部信息检索流程。

我们的选择

实时价格
模型 结论 价格
Gemini 3.6 Flash 速度快,具备真正的 1M 上下文(在 972K 处仍能召回关键信息);在聊天型单步任务中,将推理设为 minimal 后,实测单次调用成本降低了 91-97%。 默认首选 低至 $1.5/百万
DeepSeek V4 Flash 本页支持聊天的选项中价格最低,且表中缓存读取折扣幅度最大,因此重新读取较长的历史记录几乎不产生费用。它也是 MVP 的默认摘要模型。 预算之选 低至 $0.138/百万
Claude Sonnet 5 四个选项中,角色设定和写作一致性最强。预算方面需注意:相同文本的 token 数比 Sonnet 4.6 多 41%,因此应比较 token 数量,而不是标价。 质量之选 低至 $2/百万
GLM-5.2 一次预热后的工具调用轮次实测费用为 $0.0009,而 claude-opus-4-8 为 $0.0051。预热轮次延迟中位数为 6.6s,因此仅应将其用于用户预期需要等待的流程。 低成本工具调用 低至 $1.4/百万
目录
  1. 一个 MVP 聊天机器人必须具备哪些能力?
  2. 系统是怎样组成的?
  3. 模型选择器里应该放哪些模型?
  4. 模型每一轮实际会看到什么?
  5. Prompt cache 怎样工作?哪些模型支持?
  6. 怎样判断上下文即将达到上限?
  7. 达到上限时会发生什么?
  8. 记忆怎样跨会话保留?
  9. 用户点击停止时会发生什么?
  10. 某一轮失败时会发生什么?
  11. 网页搜索为什么不需要在代码里实现工具循环?
  12. Markdown 怎样在流式输出时保持稳定?
  13. 一段会话要花多少钱?
  14. 所有配置项放在哪里?
  15. 哪些功能被省略了?以后应该接在哪里?
  16. 延伸阅读

这篇指南会搭建一个完整的聊天机器人:约两分钟即可在本地运行,一个下午就能读完全部代码。它由 FastAPI 服务端和一个静态页面组成,不需要数据库,也没有构建步骤。本文会解释每个子系统的设计、每轮请求的执行顺序,以及开发过程中遇到的故障模式。完整源码位于 github.com/synthorai-io/use-caseschatbot/ 目录。所有请求都通过同一个兼容 OpenAI 的 endpoint 发出,因此模型选择器只是一个下拉框,不需要为每个模型单独集成。

git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env    # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

演示应用正在进行一轮对话:顶部栏包含模型选择器和网页搜索标签;聊天区上方显示会话统计和分段上下文进度条;下方依次是折叠的活动轨迹、带来源标签的 Markdown 回复,以及会话侧边栏

一个 MVP 聊天机器人必须具备哪些能力?

一共八项。每一项都对应一种常见的聊天机器人故障:

功能避免的问题所在位置
持久化会话(列表、搜索、重命名)刷新后忘掉所有内容的聊天应用只是 demo,不是工具storage.py
可在对话中途切换模型所有问题都使用按最难任务定价的模型,会在简单问题上浪费预算config.py
可编辑且带预设的角色设定system prompt 就是产品本身,修改它不该需要重新部署presets/
流式输出,以及能真正通知 provider 的停止按钮首个 token 前长时间没有反馈,看起来像应用卡死;假的停止按钮仍会继续计费server.py
上下文预算,以及触及上限时的压缩机制每个模型都有上下文窗口;悄悄撞上限制会让机器人表现得“越来越笨”context.py
长期记忆每个会话都要重新介绍自己,是用户感受最明显的问题之一context.py + .data/memory.md
网页搜索和网页抓取模型知识停留在训练时,而聊天问题通常很看重时效性tools.py
每轮及每个会话的成本可见性费用会随历史记录增长;看不到就无法优化server.py

网页搜索和抓取需要多解释几句,因为多数 MVP 功能清单最先删掉的就是这两项。聊天模型的知识停留在训练截止日期,通常比当前时间早几个月,而用户的问题往往非常关注现状:价格、版本、发布情况,以及“X 现在是否已经支持 Y”。如果聊天机器人仅凭训练数据回答这类问题,它不是表现差一点,而是在非常自信地给出错误答案,用户还无法判断哪些信息已经过时。问题不只在于缺少事实。开发过程中我们发现,如果没有日期锚点,模型会把训练截止日期当成“现在”,甚至会在搜索词里填入过时的年份,导致搜索本身也出错。对聊天机器人来说,检索不是锦上添花的功能,而是静态知识快照与真正助手之间的分界线。

之所以需要两个工具,是因为搜索和抓取解决的问题不同。搜索返回几百个字符的摘要,而且不同结果经常互相矛盾;它回答的是“外面有哪些信息”。抓取会取回完整页面,回答“这个页面具体写了什么”,适合核实精确数字。前者用于发现,后者用于验证。每一轮由模型决定是否使用其中任何一个;两者都不需要时,不会产生额外费用,有需要时也会受到每次调用上限的约束。

其他基础功能也都包括在内:重新生成、流式过程中不会损坏布局的 Markdown 渲染,以及针对不同故障的明确恢复路径。下面会大致按照表格从上到下的顺序讲解,也就是一条请求在代码中的实际流转顺序。

系统是怎样组成的?

三个部分:一个静态页面、六个小型 Python 文件,以及网关。页面提交消息后,服务端组装上下文,在需要时压缩,通过 server-sent events 流式返回 completion,并记录实测用量。每个会话都是 JSON 文件,可以直接用 cat 查看。

static/index.html   the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py           routes; the per-turn pipeline: project → compress → stream → record
context.py          token budgeting, compression call, memory file, message assembly
tools.py            the /v1/messages transport used by tool-enabled turns
storage.py          one JSON file per conversation under .data/ (settings included)
config.py           env-driven settings: model lineup, budgets, prompts
presets/            system-prompt presets, one .txt each

最需要理解的是 server.py 中的单轮处理管线。用户发送的每条消息都会经过相同的五个步骤,每一步都对应下文的一个章节:

user message


[1] project the next request's size     last measured prompt_tokens
    │                                   + estimate(new message, pessimistic)
    │                                   + completion reserve

[2] over budget? ──yes──▶ compress      old turns ──▶ rolling summary
    │                                       └───────▶ durable facts ──▶ memory.md

[3] assemble and send                   [persona][memory][summary][date][history]
    │                                   cache mark after the system blocks

[4] stream SSE back to the page         delta / reasoning / search / fetch /
    │                                   compression notice / warning / error

[5] record measured usage               usage.prompt_tokens becomes step [1]'s
                                        input on the next turn

这个循环会自我闭合:第 5 步的实测数值会成为下一轮第 1 步的依据。因此,预算始终以 API 实际计算的 token 为准,而不是依赖本地猜测。

还有一个需要提前说明的架构分支:普通对话请求发往 /v1/chat/completions,启用网页搜索或抓取的请求发往 /v1/messages。这不是编码风格上的选择;工具章节会说明哪些实测行为迫使我们采用这种拆分。

模型选择器里应该放哪些模型?

.env 默认提供七个模型,选择器按层级分组:快速低价、均衡、前沿。你也可以在设置中加入网关支持的任意 id,新增的 id 会持久化到 .data/models.json。相比完整列表,默认模型的选择更重要。这个 MVP 默认使用 Gemini 3.6 Flash,并将推理强度调到最低。聊天回复属于单步任务。在单步任务中,与默认设置相比,reasoning_effort: "minimal" 可将实测单次调用成本降低 91-97%,而读者无法分辨输出质量的差异。这个参数按模型配置,因为并非所有模型都支持:

# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
    "gemini-3.6-flash": {"reasoning_effort": "minimal"},
}

DeepSeek V4 Flash 在系统中承担两个角色:它既是选择器里的低成本选项,也是上下文压缩的默认摘要模型,因为摘要同样属于单步、可容忍一定质量差异的任务。Claude Sonnet 5 适合将写作质量作为核心产品能力的场景。做预算时应比较 token 数量,而不是标价,因为同一段文本在它上面产生的 token 比 Sonnet 4.6 多 41%。GLM-5.2 适合工具调用密集的流程:实测一次 warm tool-call turn 的成本为 $0.0009,而 Claude Opus 4.8 为 $0.0051;但它的 warm turn 延迟中位数为 6.6s,在聊天窗口里会明显感觉到等待。

每个模型的能力都来自实测,而不是假设。代码会根据差异调整路由,不会假装所有模型行为一致。在这组模型中,只有 DeepSeek 和 GLM 会以 reasoning_content 流式返回思考过程;即使设置 thinking 参数,Claude 模型通过这个网关也不会返回 thinking block。因此,只有确实可能收到这类内容时,UI 才会显示实时的“思考中”面板。

在对话中途切换模型,不需要修改数据结构。历史记录采用 provider 无关的 {"role", "content"} 消息格式,因此同一段对话可以先用便宜模型处理简单问题,遇到难题时再切换到质量更高的模型。真正的成本是不可见的:切换模型会放弃之前模型的 prompt cache,因此切换后的第一轮需要按 cold price 重新读取完整上下文。

模型每一轮实际会看到什么?

服务端会在一个固定位置组装分层 prompt,并按稳定性从高到低排列:

来源变化频率
System prompt(角色设定)presets/*.txt 或自由文本除非手动编辑,否则不变
长期记忆.data/memory.md很少变化(追加事实时才变)
滚动摘要上下文压缩仅在触发压缩时变化
日期锚点服务端时钟每天变化
工具指令设置,仅用于工具调用轮次很少变化
最近对话当前会话每轮变化

排序原则是:越稳定的内容越靠前,因为下一节会讲到缓存。角色设定始终不变,记忆很少变化,摘要只在压缩时更新,历史记录则每轮都变。因此,每一层都放在比它更稳定的内容之后。

日期锚点不仅必须存在,位置也很重要。没有日期锚点时,模型会把训练截止时间当成“现在”:它会在搜索词中写入过时年份,也会误把发布表中最新的一行当成当前版本。但这里故意只提供日期,不提供时间戳。日期位于 prompt 前缀中,如果精确到天以下,每次请求都会破坏缓存。它还必须放在角色设定、记忆和摘要之后,这样跨过午夜时,这些内容的缓存仍然有效。

Prompt cache 怎样工作?哪些模型支持?

基本原理是:provider 会缓存请求中逐字节一致的前缀,后续以大幅折扣重新读取,但首次写入缓存时会收取少量溢价。聊天是最适合这种机制的负载,因为每次请求本质上都是上一次请求再加两条消息,过去的全部内容天然构成稳定前缀。写入溢价在下一轮就能回本。以 Anthropic 模型为例,5 分钟 TTL 的写入价格约为 1.25x,读取则约为 0.1x;这里有写入侧成本的实测结果cache_control 标记在三个 Claude 层级上将实测成本降低了 88-89%。当会话已经包含真实记忆和摘要时,后续轮次的成本约为首次 cold turn 的十分之一。

问题在于,“缓存”并不是一种统一机制。provider 大致分成两类,而这组模型两类都有:

模型缓存方式需要做什么命中结果字段
Claude 系列显式:cache_control 断点指定标记位置cache_read_input_tokens
DeepSeek V4 Flash隐式:自动前缀缓存无需操作prompt_tokens_details.cached_tokens
GLM-5.2隐式:自动前缀缓存无需操作prompt_tokens_details.cached_tokens
Gemini 3.6 Flash隐式;接受但忽略标记无可用操作通过此网关不会上报

显式缓存采用 Anthropic 的方式:你需要指定断点,但能清楚看到发生了什么。写入和读取的 token 会分别上报,因此每轮折扣都可审计。隐式缓存属于 OpenAI 风格:无需添加标记,只要前缀重复就可能自动生效。但最终是否缓存由 provider 决定,唯一证据是事后返回的 cached_tokens 字段;不同 provider 的命中可靠性差异很大。这个 MVP 同时兼容两类机制:prompt 按稳定性从高到低排序,满足隐式缓存的前缀要求;同时始终发送显式标记,隐式缓存模型会无害地忽略它。只用一种请求结构,每个模型都能使用自身支持的缓存能力。

显式标记的位置来自实测,不是文档推断:在这个网关上,只有放在 system block 中的 cache_control 才会生效,放在其他位置会被静默忽略。常见的多轮模式是标记最新一条用户消息,从而缓存全部历史记录;但这里这样做会读回 0 个缓存 token,并按全价计费。因此,这个 MVP 将标记放在 system section 末尾,可缓存前缀为角色设定、记忆和摘要。由此会产生两个结果。第一,只有当前缀超过模型的最低门槛(约 1,024 个 token)时,缓存才会生效。因此,新会话不会缓存,而已经积累了记忆和摘要的会话会全部缓存。第二,任何修改前缀的操作都有成本:编辑 system prompt 会从第一个字节开始变成 cold;压缩会重写摘要,需要一次 cold read,后文会继续说明;切换模型则会完全放弃旧模型的缓存。

TTL 是一个配置选择,各模型的取舍类似:默认 5 分钟缓存适合活跃对话,1 小时层级适合短暂离开的用户,但写入溢价会翻倍。只需一行 CACHE_TTL=1h 即可修改;是否划算,取决于用户是否真的会在一小时内回来。

怎样判断上下文即将达到上限?

应该实测,不要猜。对于当前正在调用的模型,唯一准确的 token 数量是上一次 API 响应返回的 usage.prompt_tokens。本地估算器在不同厂商之间很容易产生误导,而且误差并不小:同一段英文文本,即使在同一厂商的两个模型之间,token 数量也会相差 41%(实测结果在这里),跨厂商差异更大。这个 MVP 只会在一种情况下本地估算:当前准备发送、API 尚未看到的新消息。为了安全,这个估算会有意向上取整:

# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
    if message_count <= config.KEEP_RECENT_MESSAGES:
        return False  # nothing old enough to fold away
    projected = (
        last_prompt_tokens                    # measured, last response
        + estimate_tokens(pending_text)       # estimated, pessimistic
        + config.MAX_COMPLETION_TOKENS        # worst-case reply
    )
    return projected > (budget or config.CONTEXT_BUDGET_TOKENS)

高估最多只会提前一轮触发压缩,代价是一次便宜的摘要调用。低估则会溢出上下文窗口,导致请求失败或被静默截断。两种后果并不对称,因此应该向上取整。

“实测”本身也有陷阱:不同 provider 对 prompt_tokens 是否包含缓存 token 的定义并不一致。有些返回完整 prompt,有些只返回未缓存的增量。这不是显示层的小问题,因为该数值是预算的事实依据。如果少算了整个缓存部分,压缩就永远不会触发,最终上下文窗口会静默溢出。服务端会做归一化处理:如果 prompt_tokens 小于上报的缓存 token 数,它显然不可能代表完整 prompt,此时就将各部分相加。同时,本地估算值会作为实测值的下限。不同 provider 的 usage 到底返回什么,可参考 LLM Token Usage 结构解析

默认预算为 102,400 个 token,普通对话通常不会触及。它是工作预算,不是模型的硬上限;正确的配置值应该是“单轮成本开始不划算”的位置。想观察机制运行,可以在设置中把某个会话的窗口降到几千个 token,然后粘贴几段长文本。顶部的上下文进度条使用实测数值绘制,并按窗口占用来源分段:角色设定、记忆、摘要和历史记录。

顶部信息条:会话统计包含缓存占比和成本;上下文进度条按 system、memory、summary 和 history 分段,并以 16k 预算为上限

达到上限时会发生什么?

压缩,而不是截断。直接截断会让机器人忘记对话开头,用户感受到的结果就是机器人“变笨了”。因此,除最近消息外的所有内容(默认保留最后 8 条)都会由低成本摘要模型折叠为滚动摘要,并作为 system block 随请求发送。近期窗口会逐字保留,所以机器人的短期语气不会变化;只有较早的历史会有损压缩。系统不会静默丢弃任何内容。触发压缩时,对话记录中会显示通知,说明处理了多少条消息,以及摘要大小。

一次完整的压缩过程如下:

before   (projected next request 103.1k > 102.4k budget)

  [persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
                            old enough to fold            last 8, kept

one summary call to deepseek-v4-flash:
  in:   prior summary + m1 ... m34
  out:  {"summary": "one dense paragraph: topics, decisions,
                     open questions, promises",
         "facts":   ["prefers Python", "timezone is UTC+8"]}

after

  [persona][memory + new facts][summary][date][ m35 ....... m42 ]
   unchanged  grows rarely      replaced         verbatim

压缩调用的可靠性主要取决于三个设计点:

  • 新摘要会吸收旧摘要。 第二次压缩会把第一次摘要和新变旧的消息一起折叠,因此始终只保留一段滚动摘要,不会形成不断增长的“摘要的摘要”链条。
  • 压缩失败不会导致本轮失败。 如果摘要调用报错,服务端会直接丢弃最早的对话,明确告知用户丢失了什么,然后继续回答当前消息。记忆不够完整,也比机器人完全不可用更好。demo 中从不触发的异常路径,往往正是生产环境里导致告警的路径。
  • 压缩 prompt 可配置,但有一项保护。 摘要模型被要求保留什么,决定了会话能记住什么,因此压缩 prompt 可按会话编辑。如果编辑后的内容不再包含 {transcript} 占位符,服务端会拒绝保存,因为这样的 prompt 实际上没有任何待摘要内容,而且问题要等到首次溢出时才会暴露。COMPRESS_MAX_TOKENS 的设计逻辑相同:输出上限要高于 prompt 要求的摘要长度,因为一段在句子中间被截断的摘要,会被后续每一轮继承。

从缓存角度看,压缩只会产生一次 cold re-read:摘要 block 发生变化,因此 memory block 之后的内容会在一轮请求中变为 cold,随后新的、更小的前缀会再次进入缓存。完整成本就是一次摘要调用加一次 cold read,换来后续每一轮都基于更小的 warm context 计费。

记忆怎样跨会话保留?

从整体看,机器人有三个记忆存储,分别对应不同的作用域和生命周期。本节所有设计都源于这种划分:

存储作用域生命周期请求中的大小
逐字保留的近期对话当前会话直到压缩将其折叠最后 8 条消息的完整文本
滚动摘要当前会话随会话删除一段文字,不超过 200 个词
memory.md 事实所有会话直到人工清理几行文本

摘要和事实看起来相似,但有效期不同,因此必须分开。滚动摘要围绕当前会话组织,包括决策、待解决问题,以及助手承诺要做的事情;它随会话结束而消失是正确行为。关于用户的持久事实,例如“偏好 Python”“时区为 UTC+8”,下周依然有效,如果也随当前会话删除,就浪费了。

压缩 prompt 会同时要求两类输出,并返回 JSON:一个 summary 字符串和一个 facts 数组。事实会追加到 .data/memory.md,每个会话都会将该文件作为 system block 加载。选择在压缩时提取事实,而不是另做一次处理,原因在于成本:压缩本来就需要让模型重新读取旧对话,因此可以顺便提取事实,不必再支付一次输入 token。一次调用得到两类输出。

这个 MVP 只按整行文本做去重,局限性很快就会暴露:一次压缩保存“User prefers Python over Node.js”,后续压缩又可能添加“The user prefers Python over Node.js.”。语义去重需要对每条候选事实做 embedding,或调用 LLM 与现有存储比较。这是一项有实际成本的独立功能,因此 MVP 先采用简单方案,并明确说明限制。记忆文件使用 Markdown,就是为了便于人工阅读和清理;设置中也将它作为可编辑文本框展示,同时充当透明度面板:机器人掌握了哪些关于你的信息,都在一个可以直接打开的文件里。

用户点击停止时会发生什么?

provider 会停止生成。这才是该功能的核心,而很多聊天 UI 并没有做到:如果停止按钮只是隐藏输出,completion 仍在后台继续运行,用户看不到的 token 仍会计费。

整条链路分为三步。回复流式输出期间,发送按钮会在原位置变为停止按钮,无需二次确认;点击后会中止浏览器的 fetch。服务端在事件之间轮询连接状态,检测到断开后退出 streaming loop,并关闭上游连接,使 provider 停止生成。已经到达的文本会被持久化并标记为 interrupted,在对话中以虚线边框显示。关闭标签页或网络断开时也会执行相同流程,因为对服务端来说,它们都是连接中断。

这里必须保证只持久化一次,代码对此做了明确处理:正常结束时在 streaming loop 内保存;流中断时由异常处理器保存;连接被强制中止时则由 finally block 保存,因为这种情况下框架会取消 generator,循环后的代码不会执行。保存函数是幂等的,因此无论哪条路径最先触发,都只会生效一次。

停止功能与重新生成配套使用:中断不想要的回复后,无需重新输入即可再次生成。重新生成会移除末尾的 assistant 消息,包括被中断的回复,让会话重新以用户消息结尾,然后使用当前模型重新回答。由于模型下拉框总是作用于下一轮,因此“停止 + 重新生成”也可以用来让更强的模型重新回答同一个问题。

某一轮失败时会发生什么?

共有六类情况。系统不会只显示一条笼统的红色错误,而是为每类故障提供对应的恢复路径。server.py 中的 classify() 会将上游异常映射为故障类型,UI 再将类型映射为操作:

故障用户看到的内容恢复方式
网络错误明确标记为网络错误重试按钮
触发限流如果存在 retry-after,显示等待时间重试按钮
API key 错误明确指出需要修复的环境变量名编辑 .env
内容过滤提示“原样重试仍会被拒绝”编辑后重新发送
流式输出中断保留已生成的部分,并标记为中断重试
压缩失败警告中明确说明丢弃了哪些内容无需恢复;本轮继续

两个约束解决了大部分问题。第一,用户消息绝不能丢失。如果请求在第一个事件到达前失败,服务端会从会话中回滚该消息,UI 则把草稿放回输入框,避免重试时重复发送。如果收到部分文本后流中断,则保留已有内容并标记。第二,被内容过滤拒绝的回复应执行 rewind,而不是 retry:服务端会从历史记录中取回用户消息,放回输入框供编辑,因为一字不改地重试只会永久得到相同结果。

这两项行为背后还有一条更隐蔽的存储规则:永远不要保存空的 assistant turn。将空 assistant 消息回放给上游,会破坏某些 provider 强制要求的 user/assistant 交替结构。更麻烦的是,错误通常会在后续某一轮才出现,而且指向错误的消息,调试非常困难。这个 MVP 会从线上请求格式中移除空内容,也不会保存没有文本的回复。唯一例外是该轮存在值得保留的搜索或推理活动;此时会保存活动记录,但发往上游时仍会剥离空文本。

网页搜索为什么不需要在代码里实现工具循环?

网关会在服务端执行工具,这改变了各层代码的职责。传统 function calling 需要由应用维护循环:模型返回 tool_calls block,应用执行工具,追加 tool_result 消息,再次发送完整会话,每次工具调用都要循环一次。服务端工具把这个循环移到了网关:请求中声明 synthorai:web_searchsynthorai:web_fetch 后,网关位于 LLM 与工具 provider 之间,将模型的工具调用转发给第三方搜索或抓取 API,再把结果放回模型上下文。工具本身并不神秘:每次搜索和抓取都是对外部 API 的一次调用,因此会按次计费。这个代码库不处理 tool_result 往返,只需渲染经过服务端的事件:

browser        server (tools.py)     Synthorai gateway       LLM / tool providers
   │ POST /chat    │                     │
   ├──────────────▶│ declare tools +     │
   │               │ budget note         │
   │               ├────────────────────▶│── question + tools ──▶ [LLM]
   │               │                     │◀── tool_use: search ── [LLM]
   │               │                     │── query ─────────────▶ [search API]
   │               │  search results     │◀── results ─────────── [search API]
   │ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
   │               │                     │◀── tool_use: fetch ─── [LLM]
   │               │                     │── URL ───────────────▶ [fetch API]
   │               │  fetch result       │◀── page text ───────── [fetch API]
   │ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
   │ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
   │ SSE: done     │                     │

职责划分很清晰:模型负责决策,包括是否需要搜索、摘要是否足够还是需要抓取完整页面,以及何时停止调用工具并开始回答;网关负责执行,调用搜索或抓取 provider,再把结果传回模型;应用服务端只负责渲染流经的事件。两个工具默认都启用,不需要时不会产生额外费用,因此算术问题仍然免费,而“当前最新……”一类问题会自动搜索。网关侧循环也有上限:如果服务端搜索循环在自己的迭代限制处暂停(pause_turn),transport 会回传该轮继续执行,最多三次。

开发过程中总结出的四点,比理想路径更重要:

  • endpoint 决定了应用能看到什么。 两个 endpoint 都会执行搜索并计费(实测日期为 2026-08-04,Anthropic 和 Gemini channel 均如此),但只有 /v1/messages 会暴露搜索过程:查询词和结果 URL 会作为类型化 block 返回,可供代码渲染和存储。在 /v1/chat/completions 上,同样的搜索会静默运行:HTTP 200,答案以“根据搜索结果……”开头,但 streaming 和 non-streaming 路径都没有 citation 或 annotation。为一项无法审计的搜索付费属于静默降级,也是最糟糕的故障形态:没有错误,只是来源悄悄消失。这正是 tools.py 必须作为第二套 transport 存在的全部原因,而不是在普通请求里多加一个字段。
  • 必须用自然语言告诉模型工具预算。 上限默认为每轮 3 次搜索、2 次抓取,由网关静默执行。如果模型不知道上限,就会按工具无限可用来规划,并在思考到一半时耗尽最后一次调用,最终只留下“让我搜索一下……”,没有答案。这个 MVP 会注入一句话说明预算,而且措辞会直接影响成本。测试中,完全不说明预算会耗尽全部工具额度,并在半句话处结束;过度严格的说明会让模型直接放弃,要求用户自己阅读页面;最终采用的措辞会完全跳过搜索,只抓取两个权威页面,三种方案中成本最低。该内容可在设置中编辑,修改后可以直接观察每轮成本。
  • 只有上限能真正控制费用。 两种工具都按次计费,调用多少轮由模型决定。测试中,一次没有上限的请求在产生第一个计费输出 token 前,就执行了三次搜索和两次抓取。
  • 应降级,而不是让整轮失败。 两类故障采用同一策略:移除工具、重试一次,并告知用户。网页抓取要求 API key 具备相应权限;如果没有,整个请求会以 web_fetch_not_enabled 失败,而不会自动降级。Gemini 则会接受工具声明,但真正调用时立即报错(Function call is missing a thought_signature)。无论哪种情况,都不应因为可选工具不可用而丢掉用户的整轮请求。

模型生成答案前执行的所有活动,包括思考、搜索和阅读,都会实时显示在回复上方的活动轨迹中。默认折叠为一行弱化文本,例如“思考 5 秒 · 搜索网页 · 阅读 2 个页面”;展开后则显示包含查询词、结果域名和推理文本的时间线。活动轨迹会随消息一并存储,这一点很关键:刷新后就消失的轨迹,无法用于之后审计答案。引用来源会以带编号的域名标签显示在回复下方。

回复上方展开的活动轨迹:搜索步骤包含查询词和两个结果;抓取步骤显示页面及其大小;下方展示来源标签

Markdown 怎样在流式输出时保持稳定?

做法是把累计文本拆成两部分:可以安全渲染的稳定前缀,以及暂时不能渲染的尾部。渲染一个尚未闭合的结构,会在后续 token 补全时引发布局跳动。因此,渲染器会在最后一行完整内容处截断;如果代码围栏已经开始但尚未闭合,从该围栏起的全部内容都会先按纯文本显示,直到闭合为止。只有稳定前缀实际增长时,消息气泡才会重新渲染,因此 streaming 不会为每个 token 重建 DOM。代码块带复制按钮。整个渲染器使用约一百行 vanilla JS,没有依赖库。这只是在说明 MVP 实际需要什么,并不是否定 Markdown 库。

一段会话要花多少钱?

以网关返回的 usage 为准,而这个 MVP 的职责是如实读取它。两个归一化问题至关重要:

  • 不同厂商使用不同字段名。 仅缓存 token 就有多种情况:Anthropic 模型返回 cache_read_input_tokens,DeepSeek 和 GLM 只返回 prompt_tokens_details.cached_tokens,Gemini 两者都不返回。统计信息会读取实际存在的字段,让“cached”始终表示同一件事。
  • 缺少成本字段不等于成本为零。 网关在部分轮次返回 cost,另一些轮次则省略。这个行为可以稳定复现,例如声明了工具但实际未使用时。如果某轮没有成本字段,系统会单独计数并显示“+N 个未上报”,而不是按零计入总和。否则,一个静默漏掉部分轮次的数字看起来会像最终账单,实际上它只是下限。

顶部栏显示当前会话的累计统计,包括输入、输出、缓存占比、搜索次数、抓取次数、成本和轮次数;每条回复也会显示自己的统计信息。最值得关注的是缓存占比:它把上文的缓存机制直接转化为当前会话的实测结果。

所有配置项放在哪里?

全部集中在一个设置面板中。最值得复用的设计不是面板本身,而是配置项的作用域:其中几乎所有设置都只属于当前会话。

设置面板:模型列表以可删除标签展示,并提供新增输入框;下方是按会话配置的上下文窗口、位于可编辑 system prompt 上方的预设下拉框,以及记忆区域

只有两项是全局设置,因为它们描述的是用户,而不是某段会话:模型列表(可添加网关支持的任意 id,并持久化到 .data/models.json)和长期记忆文件(以可编辑文本框展示)。其余内容都只作用于当前打开的会话:system prompt(可从 presets/ 中选择预设)、上下文预算、网页搜索和抓取开关、工具指令,以及压缩 prompt。因此,两段会话可以在同一组模型上并行使用不同的角色设定、预算和工具。你可以据此复现本文的结论,而不必直接相信我们的说法。

每个字段旁都有一个小型信息标记,悬停可预览,点击可固定。它会说明修改该项会产生什么成本,因为多数配置的代价无法直接从 UI 看出来:编辑角色设定会从第一个字节开始使缓存失效,因此需要一次 cold turn;改写工具指令会改变每轮费用;降低预算会让压缩更早触发。顶部栏的模型下拉框是唯一会立即保存的设置,因为对话中途切换模型属于一等操作,而不是普通配置变更。

哪些功能被省略了?以后应该接在哪里?

以下功能是有意省略的,每一项都有明确的接入点:

  • 认证和多用户——在路由前增加 session layer。会话已经有 id,因此把它限定到用户只需要给文件名加前缀,不必重新设计。在此之前,它只是本地工具:每个请求都会消耗你的 API key,不要原样暴露到公网。
  • 限流——接在同一位置,理由也相同:它用于保护共享部署,而当前系统还不是共享服务。
  • RAG——检索到的文档应放在摘要之后、近期消息之前。易变内容应该尽量靠近前缀末尾,避免反复使已缓存的角色设定和记忆 block 失效。模型层面,这正是 1M 上下文模型发挥窗口优势的位置。
  • 客户端 function calling——在 streaming loop 中增加 tool_calls 分支和执行器;GLM-5.2 工具调用详解 介绍了届时需要处理的跨 provider 协议差异。上文的网关端工具就是为了避免应用自己维护该循环。
  • 记忆语义去重——对每条候选事实生成 embedding,与现有存储比较,只保留新事实。无需修改记忆文件格式。
  • 语法高亮——用户真正会使用的功能是复制按钮;语法高亮属于正式前端的库选型问题。
  • 真正的数据库——storage.py 只有少量函数,改为 SQLite 一个下午即可完成。在真正拥有用户之前,使用 JSON 文件本来就是这里的设计目标。

延伸阅读

← 使用场景