跳转到内容

第 54 章 从 Notebook 到可用服务与可信的用户体验

米拉 想知道,2026-09-14 去北京出差,住宿限额是多少。第 46 章的函数已经能返回每间夜 750 元,以及支持这个答案的制度。在 Notebook 里,作者知道单元格什么时候开始运行,可以查看变量,也清楚哪个账户是虚构的。打开应用的用户看不到这些。如果页面一直没有动静,她该等下去,还是再问一次?按下“停止”之后,答案还会不会突然出现?如果金额先到、来源后到,她又该怎样判断?

算出答案,只完成了任务的一部分。我们还要保留用户原来的问题,让处理进度有意义,落实访问权限,并把结果解释清楚。本章继续已有的知识助手,默认沿用经过核验的原文摘录流程,从小型虚构资料库中组织答案。第 33 章的 Mini GPT 候选没有通过选型,第 38 章的格式修复也没有让它们成为可靠的制度助手。给模型加上 HTTP 接口,不会改变这些证据。

先用最简单的办法:页面调用回答函数,再把返回的字符串显示出来。这可以走通一次成功请求,却没有说明谁可以问、按哪个日期回答、连接断开后怎么办。把这些决定明确下来,并不需要先搭建庞大的分布式系统。

用户界面(user interface,UI) 收集问题和适用日期,显示进度与来源。业务逻辑(business logic) 确认调用者,校验输入,检查权限,限制工作量,并决定请求的最终状态。检索(retrieval) 找到适用于问题日期的资料,提供支持结论所需的完整范围。回答/模型层(answer/model layer) 根据这些资料准备候选答案;宿主检查结构与证据后,才允许返回。这是在分配职责,不是在规定必须使用四台机器。

CPU 主线的最后一层调用原文摘录适配器和已有的来源核验 Skill,神经模型调用次数为零。保留模型接口仍然有用:以后换成模型适配器,也要满足同样的输入、输出与停止契约,并重新通过评测。第 47 章单独测量的 Qwen 选择调用,仍只说明那项范围更窄的实验,不能算成本服务新产生的模型样本。

从问题到经过核验的答案
  1. 应用保存的记录界面:问题与 2026 年日期
  2. 处理步骤业务:确认调用者,接纳任务
  3. 模型之外检索:v2 第 1—3 行
  4. 处理步骤回答适配器与 Skill:核验 750

问题 → 获授权的任务 → 适用资料 → 核验结果 → 界面。进度可以先到,金额必须等核验完成。

停止与完成同时发生时
  1. 应用保存的记录收到停止请求:cancelling
  2. 处理步骤通知进程组停止,并等待退出
  3. 应用保存的记录只能有一个终止状态:cancelled 或 answered

停止先成功 → 不显示答案,也不写缓存。完成先成功 → 取消返回 409;用户仍可清空页面内容。

图 54.1——请求既有数据传递路径,也有独立的停止边界。 本书原创图示 · CC BY-SA 4.0。

图 54.1 把数据传递与停止、返回结果的权限分开。界面提交问题,不能自行指定一个值得信任的租户身份;来源提供制度文字,不能授予查看其他账户的权限。结果是否向用户显示,由宿主决定,不由答案里的一句话决定。

例 54.1:跟踪一次真实的本地请求。 客户端向 POST /api/requests 发送下面的 JSON,并在 Authorization 请求头中携带虚构北区账户的凭据。这个凭据是公开的演示占位值,不是生产密钥。资料库仍保留 v1、v2,排序仍沿用第 44 章的候选顺序,金额单位是元/间夜。

{"question":"Beijing lodging","on_date":"2026-09-14","topic":"travel","locale":"en"}

服务先检查调用者和全部四个字段,再分配一个不含业务含义的请求编号,返回 HTTP 202。这表示已经接纳处理,不表示已经回答。客户端可以通过 GET /api/requests/{id} 读取任务。一次成功的 GET 也可能读到“仍在运行”:HTTP 成功说明任务记录已经读到,记录里的状态才说明工作做到了哪一步。这是两个不同的问题。

表 54.1:本机回环请求实际经过的阶段

阶段实际发生了什么用户可以据此判断什么
已接纳调用者和输入通过接纳检查,已分配任务编号服务收到了这次请求
开始运行有额度限制的工作进程开始处理,没有匹配的已存答案工作正在进行
检索与组织答案日期筛选选中 v2 第 1—3 行,适配器准备好 750已有候选,还不是最终答案
核验来源经过审查的 Skill 检查原文范围、日期、来源和结论候选满足本项目范围明确的证据契约
回答完成宿主确定最终结果界面可以同时显示答案与来源

返回的来源可以直接阅读,不用再打开另一个工具:

虚构北京差旅住宿制度;金额单位为元/间夜。
自 2026-01-01 起生效,替代 travel-v1。
住宿限额为 750 元。

这是本书原创固定资料中的文字,引用位置为 travel-v2 第 1—3 行。第 1 行给单位,第 2 行说明何时适用,第 3 行给金额。只显示“750”,就丢掉了两个必要条件。若问题日期是 2025-12-31,同一份资料库会选出 v1,返回 600。因此,适用日期应当作为用户看得见、能核对的输入,不能悄悄把每个问题都解释成“今天”。

54.2 流式进度不等于逐段核验过的事实

Section titled “54.2 流式进度不等于逐段核验过的事实”

等待完整响应最容易实现,却可能让较长的处理过程看起来像卡住了。流式响应(streaming) 允许连接在工作尚未结束时,陆续传回记录。本例按已经核对的 HTML 标准组织事件流:先写 ID,再写一行 JSON 数据,最后以空行结束。例如,实际发出的一个事件采用下面的结构:

id: 2
data: {"id":2,"stage":"running","elapsed_ms":5.057}

这里的耗时来自首次保存的本地运行记录,单位是毫秒,从任务接纳时开始计时。客户端读取的是完整事件,不能把任意网络数据块当成事件:一个数据块可能在 JSON 字符串中间结束,另一个也可能包含好几个事件。本例的小型 fetch 读取程序会暂存尚未完整的文字,等到空行边界再处理。它只支持服务实际发出的格式子集,不是通用 EventSource 程序库。

这条摘录答案很短,先显示“6”,再显示“00”,最后纠正为“750”,只会制造本来不必出现的信任问题。因此,服务先传进度,把经过核验的答案作为一个最终结果返回。将来如果接入流式生成的模型,就要明确决定:尚未核验的文字怎样标识,哪些使用方式应当阻止,后续检查失败时怎样处理已经被用户读到的内容。后来补一条警告,不能让用户“没看过”那个数字。

事件连接断开,也不会自动取消任务。重新连接时,可以带上 Last-Event-ID,读取后来保存的事件,无须重新计算答案。最终状态不明时,客户端最多重试读取一次,不会悄悄创建第二个任务。这些都是应用通过接口明确作出的安排,不是“流式”这个词自带的保证。

54.3 等待和重试,都要有共同的上限

Section titled “54.3 等待和重试,都要有共同的上限”

本服务最多同时处理两个请求。两个位置都被占用时,第三个请求会收到 503 和重试提示,不会进入一条无限增长的队列。并发(concurrency) 表示工作在时间上重叠,不保证 CPU 同时执行每一条指令,也不保证每秒完成的请求数翻倍。两个名额限制的是活动回答进程,不能让这个教学 HTTP 服务抵御恶意连接洪泛。

例 54.2:重试也计入同一个截止时间。 这里采用教学给定时间,不是服务实测耗时。一个请求从到达到截止共有 1,000 ms。排队花去 120 ms,检索花去 80 ms,一次失败的下游尝试花去 300 ms。重试前再等待 100 ms,剩余时间就是 1000−120−80−300−100=4001000-120-80-300-100=400 ms。不能因为尝试编号变了,就再给第二次尝试完整的 1,000 ms。

用 DD 表示单调时钟上的截止时刻,tt 表示同一时钟的当前读数。单调时钟用于测量经过了多久,不会因为校准日期时间而被拨回。剩余时间为:

R=max⁡(0,D−t).R=\max(0,D-t).

RR、DD、tt 必须使用相同的时间单位。如果请求在时钟读数 0 ms 时到达,那么 D=1000D=1000,第二次尝试开始时 t=600t=600,所以 R=400R=400 ms。若重试前等待的不是 100 ms,而是 550 ms,就要到 1,050 ms 才能开始,此时 R=0R=0,不应再发起尝试。取最大值,是为了防止负数超时值在某个程序库里意外获得另一种含义。

实际服务的截止时间为三秒,另外人为加入 50 ms 演示等待,最多尝试两次下游操作,两次之间等 50 ms。这些设置是给定值;观察到的耗时还包括真实的 Python 启动与来源辅助程序执行,不能拿来当模型速度测评。HTTP 和子进程操作也分别限制等待时间。宿主在明确的检查点核对截止时间,必要时停止工作进程;进程清理还会增加有限的调度开销,因此这不是硬实时保证。

超时回答的是“这位观察者是否已经等到上限”,通常不能直接回答“操作到底有没有发生”。本例允许再次读取虚构订单;若要重试已经批准的取消操作,就需要第 49 章的持久意图、回执和恢复检查。本服务没有修改订单的接口,“停止”按钮停止的是回答任务,不是取消订单。

54.4 取消要赢得竞态,不能只藏起结果区域

Section titled “54.4 取消要赢得竞态,不能只藏起结果区域”

假设 米拉 在回答进程运行时按下“停止”。只在浏览器里设一个标记,后台仍会继续消耗资源;只在服务端设一个标记,也不会自动终止任意 Python 代码。宿主必须通知活动进程组停止,等待清理完成,并阻止稍后再返回答案。

例 54.3:两种可能先发生的结果。 完成之前,任务尚未进入终止状态。取消请求先把它改成 cancelling,界面说明正在停止。工作进程退出后,宿主才记录 cancelled。完成与取消共用一把锁,让同一时刻只有一个线程能修改受保护的状态。如果取消先成功,最终结果不含答案,也不写入回答缓存;如果完成先成功,取消会返回 409 和已完成状态,界面说明请求已经结束。

实际取消测试会等到一次尝试开始,再发送真实的 HTTP 取消请求,工作进程停止后还要再次检查状态。测试要求不能出现迟到的 answered 事件,也不能留下缓存项。这比检查按钮是否变色更有说服力。进程组实现已在 macOS CPU 环境执行;程序明确要求 POSIX,没有声称支持 Windows。

首次记录暴露了一个更隐蔽的问题。事件顺序是:

attempt → cancel_requested → worker_stopped → dependency_failure → cancelled

答案虽然没有返回,但 InterruptedError 被范围更大的 OSError 处理分支捕获,误记成一次下游失败。补上的断言使这条记录无法通过。把取消单独处理后,顺序修复为:

attempt → cancel_requested → worker_stopped → cancelled

证据包保留了原始观察和对应回归检查。服务对用户表现正确,仍可能误导运维人员。第 56 章会继续说明,错误的事件分类怎样影响告警。

54.5 结果出来以后,帮助用户作下一步判断

Section titled “54.5 结果出来以后,帮助用户作下一步判断”

带引用的答案更容易检查,但引用不能保证原始资料在现实世界中确实具有权威性。因此,界面说明资料是虚构的,同时保留来源日期和单位,不给一条确定的原文摘录附上没有依据的“置信度百分比”。

表 54.2:不同结果,需要不同解释

实际请求或操作观察到的结果有用的下一步
查询现行住宿制度750,附 v2 的完整支持范围阅读原文,核对适用日期
在差旅资料库中查询 volcano没有支持资料,暂不作答细化问题,或询问制度负责人
查询 overnight accommodation ceiling存在相关制度,却仍暂不作答保留这个词汇覆盖失败,纳入评测
北区账户读取自己的 A-104虚构订单快照显示“处理中”区分快照与商家的实时记录
北区账户请求无法访问的 B-205拒绝访问,没有修改订单使用有权限的途径,不能换身份绕过
下游持续不可用返回错误,不编造制度答案等待,或准备说明交给人工处理

两次暂不作答,都可以向用户说明“依据不足”,但维护工作不同。前一个问题不在资料范围内,后一个则暴露了已知的词汇缺口。日志和回归样例应当保留这一区别。拒答说得礼貌,不能把漏掉相关资料算成回答质量成功。

本地人工接管(handoff) 功能会准备一段可见说明,列出问题和日期。用户可以自行复制给制度负责人;应用既不发送,也不声称已经有人接手。撤销(undo) 则恢复刚才清空的问题与结果,只反转这次界面变化。若订单已在别处取消,需要另外的业务补偿,而且未必能补偿。

无障碍设计让这些区别更容易被不同用户理解和操作。页面提供看得见、与控件关联的标签;原生控件支持键盘访问;焦点清楚可见;简短的状态区域播报进度,不在每次事件到达时抢走焦点。来源文字可以选择,窄屏也能阅读。这些做法依据 W3C 的相关指南。对标签、焦点和布局的浏览器检查,只能证明相应范围内的行为,不能代替完整的无障碍认证,也不能代替使用辅助技术的用户测试。

工程实践之前要读 HTTP Primer:能够区分客户端与服务端,读懂 JSON 和状态码,避免把凭据写进日志,并解释为什么超时不证明操作没发生。Primer 已经提供可运行的本机回环脚手架。本章新增的阅读台阶是任务(job):由服务端保存、可以在首次 HTTP 响应之后继续存在的记录。事件 ID 表示这个任务内部的记录顺序,不是用户身份。

表 54.3:不同软件层面的证据

层面可以观察和检查什么还不能证明什么
纯规则日期与缓存到期边界,剩余时间网络行为
组件RAG 与实际执行的已审查 Skill 子进程界面表达是否让用户理解
集成真实 HTTP 创建、事件读取、取消、归属和错误生产流量分布下的可靠性
界面提交、可读来源、键盘焦点、清空与撤销、人工说明和反馈完整的辅助技术使用体验
概率性评测前文保留的模型样本与留出标准不能由前面任何软件检查代替

核心入口是 code/knowledge-assistant/service.py,记录程序和测试位于 code/part-ix/。两个语言界面读取 service-content-v1.json,使用同一份数据与执行逻辑。按项目 README 准备 CPU 环境后,可运行:

终端窗口
python code/knowledge-assistant/service.py --port 0
python -m unittest discover -s code/part-ix -p 'test_*.py' -v

端口 0 表示请操作系统分配一个可用的本地端口,应打开这个进程实际打印的地址。不需要模型账户、GPU、付费 API 或外部模型服务。程序只绑定 127.0.0.1。选用 Python HTTP 服务是为了教学,不是推荐直接用于生产;配套的带日期在线说明记录了实际测试版本和命令。

至此,KA-9 已有工作量受限的 CPU 演示、真实请求/取消/错误记录、支持无障碍操作的界面,以及跨软件边界的检查。任务和反馈保存在内存中,重启会结束这次会话。订单恢复仍使用第 49 章单独实现的持久 SQLite 机制。把存续时间说清楚,才能避免把“有日志文件”误当成“任务可以持久恢复”。

可用的服务把一次计算变成了责任明确的请求。宿主负责身份、接纳、截止时间和终止状态;检索提供适用资料;回答层准备候选;界面帮助用户检查结果并决定下一步。答案尚未核验时,可以先传进度。取消、重试与撤销,各自都需要说清楚会产生什么影响。软件测试能够检查已经覆盖的路径,却不能据此证明模型普遍答得好。

现在已经能检查一次请求的经过。下一步要追问:究竟是哪些版本产生了它,某次修改破坏契约后,又怎样恢复?

  1. 例 54.2 仍给 1,000 ms 总额度,把排队改成 200 ms,重试前等待改成 250 ms,检索和首次尝试耗时不变。第二次尝试还可以用多久?若只设一个 800 ms 的单次超时,不加其他限制,是否合适?
  2. 事件流先显示 600,随后来源核验失败,再显示“请核对”。为什么最后这条提示没有完全修复使用体验?请为制度查询提出一个具体替代方案。
  3. 南区知道北区的请求编号,并携带自己的有效凭据来读取。哪项检查必须拒绝它?为什么很长的随机编号和成功鉴权都不够?
  4. 取消接口在工作进程清理完之前就返回了。设计一个可观察的测试,区分“已请求停止”和“已经停止”,并说明之后绝不能出现什么。
  5. 服务的 HTTP 测试全部通过,却仍对表 54.2 的同义表达暂不作答。现在能否声称它能可靠理解制度问题?还需要什么证据与修复?
  1. 剩余 1000−200−80−300−250=1701000-200-80-300-250=170 ms。只设 800 ms 会超过请求总额度。应取常规单次超时与 170 ms 中较小的值,还要另外考虑清理开销。
  2. 用户可能已经读到甚至使用了 600,警告不能撤回这次接触。对这种短答案,可以只流式显示进度,核验完整且适用的来源后,再一起显示金额与引用。核验失败就明确暂不作答,不显示未经核验的金额。
  3. 任务归属授权检查必须比较已认证的租户/用户与任务所有者,并拒绝这次读取。随机编号只能降低猜中的机会,不能授予权限;鉴权识别出南区身份,也不会让南区成为北区任务的所有者。
  4. 启动一个人为延长的请求,等一次尝试开始,再发取消;清理完成前应处于 cancelling。之后要求只有一个取消终止结果,不能再返回答案或写缓存。还应测试相反顺序:已经完成的请求保持完成,取消接口说明来迟了。
  5. 不能。HTTP 行为只说明这些案例中的传输与状态控制符合约定,没有证明词汇覆盖充分。应保留失败表达,检查切分与检索,在同一评测集上比较修复方案,并继续保留来源和日期检查。前文模型选型失败的证据也仍然有效。