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

一个 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,然后粘贴几段长文本。顶部的上下文进度条使用实测数值绘制,并按窗口占用来源分段:角色设定、记忆、摘要和历史记录。

达到上限时会发生什么?
压缩,而不是截断。直接截断会让机器人忘记对话开头,用户感受到的结果就是机器人“变笨了”。因此,除最近消息外的所有内容(默认保留最后 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_search 或 synthorai: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 个未上报”,而不是按零计入总和。否则,一个静默漏掉部分轮次的数字看起来会像最终账单,实际上它只是下限。
顶部栏显示当前会话的累计统计,包括输入、输出、缓存占比、搜索次数、抓取次数、成本和轮次数;每条回复也会显示自己的统计信息。最值得关注的是缓存占比:它把上文的缓存机制直接转化为当前会话的实测结果。
所有配置项放在哪里?
全部集中在一个设置面板中。最值得复用的设计不是面板本身,而是配置项的作用域:其中几乎所有设置都只属于当前会话。

只有两项是全局设置,因为它们描述的是用户,而不是某段会话:模型列表(可添加网关支持的任意 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 文件本来就是这里的设计目标。
延伸阅读
- 2026 年按场景选择最佳 LLM:聊天、RAG 与 Agent 成本矩阵——将本项目优化的成本公式应用到不同负载形态。
- Gemini 3.6 Flash:让成本相差 30 倍的思考强度设置——将推理强度固定为 minimal 的实测依据。
- Claude Sonnet 5 的 tokenizer——为什么跨模型比较时应看 token 数量,而不是价格。
- LLM Token Usage 结构解析——不同 provider 的
usage实际包含哪些数据。 - GLM 5.2 工具调用——function calling 扩展中的 warm turn 成本和协议差异。