旧工具结果写进新回合:两次消费之间的打断窗口
用户让语音 Agent 查询订单,随后说“停,先查另一单”。旧查询的结果已经进入后台队列,但负责把结果写进对话历史的消费者还没处理它。等这个消费者醒来,当前回合已经换了。它若把“任务完成”直接解释为“写入当前回合”,下一轮模型便会拿到上一轮订单的结果。
本文用一个临时 SQLite 文件固定这个窗口。同一条完成事件,在投影消费者先运行时写入回合 1,在打断先运行时却被错误写入回合 2;加入提交时检查后,后者留在后台,不能进入新回合。四条时序都真实运行,但输入是作者构造,未连接订单系统、模型、音频或真实消息队列。
核心判断是:后台任务是否完成,与结果此刻能否进入前台对话,是两个独立状态。把取消标记写成任务失败,会丢掉真实结果;只在结果入队时检查回合,又会把过期许可带到下一次消费。
一条结果为什么需要两次消费
先把故障压到最小。系统只有一个会话、两个回合和一个旧工具任务。结果接收消费者负责记录 job-1 已完成,并生成待投影事件;投影消费者负责把这条事件转成模型能读到的对话历史。二者可以在不同时间运行。
示例中的投影不是音频播放,也不是执行工具。它是从持久任务结果到前台上下文的派生写入。这个入口容易被忽视:播放器已经停了、旧 response 也失效了,但一条“工具完成”的后台订阅仍可能更新共享的 current conversation。
固定输入如下:
| 顺序 | 接收消费者 | 用户动作 | 投影消费者 |
|---|---|---|---|
| 投影先完成 | 保存旧任务结果并排队 | 此时还没打断 | 先写回合 1,再打断并启动回合 2 |
| 打断先完成 | 保存同一个结果并排队 | 撤销回合 1 前台写权,启动回合 2 | 后消费相同的排队事件 |
两条顺序没有改变工具结果,只改变投影发生的位置。第一条中,结果合法地写入旧回合;打断后它仍属于回合 1 的历史事实。第二条若使用“当前回合”作为目标,会把旧任务结果归给回合 2。这不是工具执行了两次,而是一个已完成结果被投影到了错误的地方。
只在接收时算 allowed=True 也救不了第二条:接收时旧回合确实有效,许可会在排队期间过期。排队事件需要保留来源回合,真正写前台的消费者仍须读取当前状态,不能继承过去的布尔判断。
后台记录应当活得比前台许可久
任务表保存 job_id、所属回合、任务状态与结果。回合表另存前台是否开放;会话表保存当前回合。打断操作在同一 SQLite 事务中关闭旧回合,再开放新回合。它没有把旧任务的 completed 改成 canceled。
这样,旧查询即使不能再回答当前问题,也仍可被查询任务状态、处理账单或审计结果的流程识别。后台保留不等于允许把旧正文到处复制:实际记录仍需按业务用途决定保存内容、访问权限和期限。本例所有内容都是固定字符串。
Python 3.11 的 Future 文档说明,正在运行或已经完成的调用不能靠 Future.cancel() 取消;shutdown(cancel_futures=True) 也只取消尚未开始的 Future。这个来源用于说明本地取消 API 的范围,不据此推断任何订单服务的撤销能力。本文没有调用 Future;使用串行时序,是为了让错误每次都能复现。
前台撤权同样不能替外部系统撤回操作。假如旧工具不是查询而是发消息,外部消息可能已经发出。投影被隔离只能证明这条结果不再自动进入当前对话,不能证明消息未发,也不能让系统自动发送另一条“补偿消息”。补偿需要新的业务判断与授权,本例完全不启动它。
完整最小例
保存为 late_result_demo.py,用 Python 3.10 或更高版本运行 python3 late_result_demo.py。只使用标准库,并在临时目录创建、重连和清理 SQLite 文件。
ingest 的完成事件由代码直接提供,代表已经核验的固定输入;代码没有验证远端调用是否真的完成。project 的两条分支使用同样的输入,区别仅在是否检查来源回合仍为当前且前台开放。为展示反例,未防护分支故意把结果写入当时的当前回合。
"""Two ordered consumers on a temporary SQLite file; no real tool or audio."""
import sqlite3
import tempfile
from pathlib import Path
def connect(path):
return sqlite3.connect(path, isolation_level=None)
def setup(db):
db.executescript("""
CREATE TABLE session(active INTEGER NOT NULL);
INSERT INTO session VALUES(1);
CREATE TABLE turns(id INTEGER PRIMARY KEY, foreground_open INTEGER NOT NULL);
INSERT INTO turns VALUES(1, 1);
CREATE TABLE jobs(id TEXT PRIMARY KEY, turn INTEGER, status TEXT, result TEXT);
INSERT INTO jobs VALUES('job-1', 1, 'running', NULL);
CREATE TABLE projection(event TEXT PRIMARY KEY, turn INTEGER, result TEXT,
decided TEXT NOT NULL DEFAULT 'pending');
CREATE TABLE history(event TEXT PRIMARY KEY, turn INTEGER, result TEXT);
""")
def ingest(db):
# Trusted fixture completion: records the task outcome, not current speaking rights.
with_transaction(db, "UPDATE jobs SET status='completed', result='fixture-result' "
"WHERE id='job-1'")
db.execute("INSERT OR IGNORE INTO projection(event, turn, result) "
"VALUES('event-1', 1, 'fixture-result')")
def with_transaction(db, sql):
db.execute('BEGIN IMMEDIATE')
try:
db.execute(sql)
db.execute('COMMIT')
except BaseException:
db.execute('ROLLBACK')
raise
def interrupt_and_start(db):
db.execute('BEGIN IMMEDIATE')
try:
db.execute('UPDATE turns SET foreground_open=0 WHERE id=1')
db.execute('INSERT INTO turns VALUES(2, 1)')
db.execute('UPDATE session SET active=2')
db.execute('COMMIT')
except BaseException:
db.execute('ROLLBACK')
raise
def project(db, guarded):
# shortcut: one local DB and trusted events; distributed delivery/auth need separate tests.
db.execute('BEGIN IMMEDIATE')
try:
event, turn, result, decided = db.execute('SELECT * FROM projection').fetchone()
if decided == 'pending':
active = db.execute('SELECT active FROM session').fetchone()[0]
opened = db.execute('SELECT foreground_open FROM turns WHERE id=?', (turn,)).fetchone()[0]
allow = not guarded or (turn == active and opened == 1)
if allow:
db.execute('INSERT INTO history VALUES(?, ?, ?)', (event, active, result))
db.execute('UPDATE projection SET decided=? WHERE event=?',
('written' if allow else 'quarantined', event))
db.execute('COMMIT')
except BaseException:
db.execute('ROLLBACK')
raise
def run(order, guarded):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'state.sqlite'
db = connect(path)
setup(db)
ingest(db)
db.close()
db = connect(path) # Reopen: completion and pending projection survived the connection.
assert db.execute('SELECT status FROM jobs').fetchone()[0] == 'completed'
if order == 'project_first':
project(db, guarded)
interrupt_and_start(db)
else:
interrupt_and_start(db)
project(db, guarded)
project(db, guarded) # Redelivery does not append a second history event.
history = db.execute('SELECT turn FROM history ORDER BY event').fetchall()
decision = db.execute('SELECT decided FROM projection').fetchone()[0]
assert db.execute('SELECT status FROM jobs').fetchone()[0] == 'completed'
db.close()
return [r[0] for r in history], decision
def main():
cases = [
('project_first', False, ([1], 'written')),
('interrupt_first', False, ([2], 'written')),
('project_first', True, ([1], 'written')),
('interrupt_first', True, ([], 'quarantined')),
]
for order, guarded, expected in cases:
actual = run(order, guarded)
assert actual == expected, (order, guarded, actual, expected)
print(f'{order}; guarded={guarded}; history_turns={actual[0]}; projection={actual[1]}')
print('all_cases: durable_job=completed; external_effect=unverified; compensation=not_started')
print('PASS 4 constructed schedules; no audio, network, or concurrent workers')
if __name__ == '__main__':
main()
本机实际运行输出如下;运行环境、退出码与文件摘要保存在附件 examples/late-result-runtime-receipt.json;这些是母稿阶段的同一次实际运行记录,本次代码未改,未另行制造新执行。
project_first; guarded=False; history_turns=[1]; projection=written
interrupt_first; guarded=False; history_turns=[2]; projection=written
project_first; guarded=True; history_turns=[1]; projection=written
interrupt_first; guarded=True; history_turns=[]; projection=quarantined
all_cases: durable_job=completed; external_effect=unverified; compensation=not_started
PASS 4 constructed schedules; no audio, network, or concurrent workers
四行结果怎样读
第一行中投影先完成,未防护实现写入回合 1。第二行只调换打断和投影的先后,历史归属就变成回合 2;这就是待修复的错误。此时旧任务依然完成,不能通过把任务改成“取消成功”解释掉错误归属。
第三、四行开启防护。投影先于打断时仍合法写入 1,说明规则没有把所有旧任务结果一律丢弃;打断先发生时不写任何前台历史,排队事件转成 quarantined,而持久任务仍为 completed。隔离状态表示该事件没有写入当前上下文,不表示内容已经物理删除。
每条时序在 ingest 后都关闭连接再打开,断言任务状态还在。它还将投影消费者再次调用一次,确认同一事件不会追加第二条历史。这里确实使用了本地数据库持久化与事务;没有启动两个并发 worker,不能将四条串行调度称为并发穷举或线上恢复测试。
jobs 更新与 projection 插入在本例中是分开的提交,脚本没有在二者之间注入崩溃;因此它也没有证明任务完成到待投影事件的可靠投递。生产系统可在同一数据库事务写入任务结果与 outbox,再由消费方处理;若跨存储,仍需对账。不要把本文的“连接重开后记录存在”扩大成所有崩溃点都不会丢事件。
检查与写入必须共享提交边界
SQLite 事务文档说明 BEGIN IMMEDIATE 立即启动写事务,其他写事务占用时可能返回 SQLITE_BUSY。示例的防护分支在 BEGIN IMMEDIATE 到 COMMIT 之间读取当前回合与开放状态,再登记历史和投影决定。打断也更新同一个数据库内的状态。这个布局使固定的两个顺序都能得到可解释结果;它没有测试多连接争锁、超时或 SQLite 在特定部署中的吞吐。
如果把历史改写到另一个服务,不能先查到“仍有效”,释放事务后再无条件发送。原来的窗口会在这两个动作之间重新出现。可把已验证投影写成带回合身份的 outbox,并要求最终消费者仍以来源回合登记,不可偷偷改绑到当前回合;真正产生前台可见效果的最后入口也需要同一有效性判断。
同样,客户端播放和 UI 是其他消费入口,必须有各自的来源身份与过期处理。本文没有再次给出语音检测、播放确认或历史截断教程,而是聚焦经常独立存在的“后台任务结果进入上下文”这一条路径。真实接入时可沿这条路径追踪:完成事件在哪里持久化,排队时保存的是身份还是许可快照,最终由谁写入哪一轮。
隔离后也可能需要告知用户旧任务结果。应把它作为“上一项任务的状态”显式呈现,或在用户重新查询时返回,而不能装成新问题的直接答案。高后果任务还需产品定义何时主动提示;系统不能用“避免污染对话”为理由静默隐藏已经发生的重要外部动作。
沿着五张表,找到真正的前台写入口
排障时先保留一个具体 job_id,从完成事件追到历史写入,避免只比较客户端上“已打断”和服务端上“已完成”两个标签。它们可以同时正确:一个描述回合出口,另一个描述后台任务。真正的矛盾是,后台结果来自回合1,历史记录却被归入回合2。
示例的 jobs 是任务事实,projection 是尚待处理的派生事件,history 是模型可能读取的前台历史,session 与 turns 则决定哪个回合仍有写入资格。projection 和 history 都用事件身份避免同一个事件被反复登记,jobs 的身份用来保留原任务。两类ID不要互相替代:一个任务可能有进度与终态等多个事件,一条事件也不能因换了消费者就获得新的业务含义。
真实日志中可按同一条事件记录来源任务、来源回合、投影目标回合、观察到的当前回合和最终决定。若只有“写历史成功”而没有目标回合,错误写入也会被统计成成功;若只有“旧事件丢弃”,又分不清它是否还留下了后台终态。这里需要的结果是两个独立观察:任务结果是否可靠保存,前台是否接受了这次投影。
这个查询路径还会暴露命名造成的隐患。一个叫 appendToCurrentConversation 的函数,可能在调用瞬间才读取当前回合;上游传入的结果明明带着来源身份,最后一层却把它丢了。修复入口应尽量接收明确的来源与目标,而不是让一个共享的“当前”指针悄悄决定归属。本文未检视任何真实系统的函数,这只是读者可以在自己的调用链上核查的位置。
为什么同时检查来源回合和开放状态
代码里的条件包含两项:事件的 turn 等于当前 active,且它对应的 foreground_open 仍为1。前者阻止来源1的事件进入当前2;后者描述一个回合尚未切换、却已经撤回写权的中间语义。两项职责不同,不要把其中一项当成装饰字段。
不过,本例把“撤销旧回合”和“开始新回合”放在一个事务里,外部不会在这次提交之间观察到“active仍是1但open已经是0”的单独状态。因此四条运行结果只对组合条件作了验证,没有用独立输入证明两项各自都是必要条件。真实产品若允许说“停”后留在同一个回合、稍后才开始新回合,应另加只撤权不换代的场景;不能从当前四行输出推断那个场景已经通过。
同理,示例中的回合ID是固定整数1和2,不会复用。真实会话恢复若把旧ID重新当成新回合,就可能让迟到事件碰巧匹配。需要把ID的唯一范围说清楚,例如以会话身份和回合身份共同定位;连接重建也不能隐式清空业务代际。本文没有实现多会话或跨设备恢复,读者应把这条限制留在接入验证中。
重复消费与丢事件是两种不同故障
project 再调用一次时先读到 decided 已是 written 或 quarantined,不会再次插入历史。它测试的是同一条已持久排队事件的重复处理。INSERT OR IGNORE 则避免同一个固定事件ID重复进入待投影表。两处合起来仍不能推出任意重复载荷都安全:若相同事件ID对应不同结果,真实接收端应核对来源和内容摘要,不能静默接受后来的替换。
对于未防护分支,幂等并不能修正第一笔错写。它只是稳定地保留那条归入2的历史,不再新增第二条。把重复率降到零与把归属写正确是两个目标;只监控“重复记录数”会漏掉本例的主要错误。
另一方面,任务结果已经提交、投影事件还未插入时若进程退出,后台可见完成,前台却永远等不到通知。这正是本例保留的双写窗口。修复归属与修复可靠投递各有验收对象:前者看来源回合和目标回合,后者看完成记录是否总能生成可恢复事件。不要为解决前者就宣称后者也由事务覆盖,因为当前 ingest 确实把两个动作分开提交。
采用同库 outbox 时,可以让任务终态与待处理事件一同提交,再由后台投递者重试。消费者依旧要检查当前回合,因为可靠投递只保证事件有机会到达,并不会阻止一条正确送达的旧结果污染新上下文。此处是接入建议,代码没有改造成 outbox,也没有运行发送器故障恢复测试。
把这个最小例接成一条真实验收记录
先在测试环境固定同一份完成结果,在接收消费者与投影消费者之间设置可控暂停点。第一次放行投影后再打断,确认结果仍归旧回合;第二次先完成打断和新回合建立,再放行相同结果,确认后台终态存在、当前历史不新增旧结果。暂停点必须处在真实写入口之前,若只暂停页面展示,服务端上下文可能早已被污染。
每次记录任务与事件身份、暂停和放行位置、前后台两份最终状态,以及客户端实际拿到的上下文片段。测试内容应使用不会触碰真实订单的构造结果。这里要求的是能比对的输入和状态,不必为此公开完整用户语音或业务正文;记录经过授权且足以判断身份与归属的最小信息即可。
随后单独覆盖投影重试、消费者重启、接收进程在两个提交之间退出,以及历史写服务超时。超时不等于没有写入,恢复时要按原事件查询或幂等登记,不能换新事件ID重写一次。若历史与授权状态跨服务,还需把最后一次检查和真正可见的时刻分开记录,找出是否仍有未受保护的发送窗口。这些是由本例暴露的接入问题,不在四条已运行序列的通过范围内。
用户反馈也要与两份状态配对。对于只读查询,可以在当前问题继续进行时把旧查询标为“结果已保存,可另行查看”;对于已经产生重要外部动作的工具,则应按业务规则显式提示原动作的状态。两者都不应把旧结果当作新问题的答案,也不能因为前台已安静就称外部动作已撤销。最终拿这条双消费者时序去定位自己的写入口,才是比多加一个 cancel=true 更可复查的交付。