Python流式取消与部分结果:不可变状态机、终态冻结和15项离线测试

本文实现一个纯Python流式状态归约器,支持局部取消、终态冻结与部分结果交付。核心包括不可变状态机设计、local_cancel本地取消验证、15项离线单元测试覆盖delta/complete/transport_error等事件类型,以及max_chars/max_events本地策略约束。强调输入合同校验、单顺序消费者语义、终态幂等性与协议适配层职责分离,不依赖外部模型或网络。

点击停止后保留半段文字,并不是一个简单的字符串拼接问题。真正需要约束的是:哪个状态允许交付、谁能写入结果、迟到的事件还能不能改变结论。

这篇实现一个纯Python状态归约器:输入一个不可变状态和一个应用内规范事件,返回新状态。它不解析SSE、不连接任何模型,也不替你推断厂商的完成语义。适合在真正的协议适配层之后,学习部分结果如何隔离与验收。

环境与文件

Python 3.11及以上,标准库,无需pip安装、API Key或账户。建立partial-lab目录,保存下面三个文件:partial_state.py、cancel_probe.py、test_partial_state.py。进入该目录执行python -m unittest -v和python partial_state.py;另运行python cancel_probe.py观察真实本地任务取消。

输入合同与终态规则

每个事件必须是普通dict,只有attempt、kind两项;delta还必须有字符串text。多余字段、错误类型和不匹配attempt直接抛出固定错误码,原状态不变。attempt由调用方为每次生成创建,不是鉴权凭证。

kind支持delta,以及complete、local_cancel、transport_error、eof、rejected。此处eof表示缺少完成证据的流结束,complete只能由上游已核对的响应级完成条件产生。禁止看到一段文本done就直接产生整次complete。

采用第一个被接受终态冻结的应用政策:相同终态重放不改变计数;其他迟到终态或增量报already_terminal。它要求单个顺序消费者,不提供线程安全、数据库事务或远端请求幂等。

max_chars按Python len统计字符串长度,不能换算Token或实际内存字节。超长块整块丢弃,保留原前缀,进入limit_reached。max_events是本地允许接受的事件数,包含终态;若先耗尽,后来的complete也不能把结果提升为成品。生产接入应固定一次会话的上限,不要中途增大上限试图恢复已冻结状态。

完整状态机:partial_state.py

"""Normalized local events, NOT a vendor streaming-protocol adapter."""
from dataclasses import dataclass, replace


@dataclass(frozen=True)
class State:
    attempt: str
    parts: tuple[str, ...] = ()
    phase: str = "running"
    chars: int = 0
    events: int = 0

    @property
    def preview(self):
        return "".join(self.parts)

    def accepted_text(self):
        if self.phase != "complete":
            raise ValueError("incomplete_result")
        return self.preview


TERMINALS = {"complete", "local_cancel", "transport_error", "eof", "rejected"}


def advance(state, event, *, max_chars=10000, max_events=1000):
    # State is constructed by this process; serialized user states are not trusted.
    if any(type(n) is not int or n < 1 for n in (max_chars, max_events)):
        raise ValueError("invalid_limit")
    if type(event) is not dict:
        raise ValueError("invalid_event")
    kind = event.get("kind")
    if type(kind) is not str or kind not in TERMINALS | {"delta"}:
        raise ValueError("invalid_kind")
    keys = {"attempt", "kind", "text"} if kind == "delta" else {"attempt", "kind"}
    if set(event) != keys or type(event["attempt"]) is not str:
        raise ValueError("invalid_fields")
    if event["attempt"] != state.attempt:
        raise ValueError("wrong_attempt")
    if kind == "delta" and type(event["text"]) is not str:
        raise ValueError("invalid_text")
    if state.phase != "running":
        if kind == state.phase:  # Repeated same terminal: no counter changes.
            return state
        raise ValueError("already_terminal")
    if state.events >= max_events:
        return replace(state, phase="limit_reached")
    if kind == "delta":
        size = len(event["text"])
        if state.chars + size > max_chars:
            # Preserve old prefix; discard the whole oversized chunk.
            return replace(state, phase="limit_reached", events=state.events + 1)
        return replace(state, parts=state.parts + (event["text"],),
                       chars=state.chars + size, events=state.events + 1)
    return replace(state, phase=kind, events=state.events + 1)


def main():
    for terminal in ("complete", "local_cancel", "eof"):
        s = State("run-1")
        for event in ({"attempt": "run-1", "kind": "delta", "text": "半段文字"},
                      {"attempt": "run-1", "kind": terminal}):
            s = advance(s, event)
        print(s.phase, repr(s.preview), s.phase == "complete")


if __name__ == "__main__":
    main()

State只由本进程构造;如果接收用户上传或数据库反序列化的State,需要另做状态字段校验,不能把本例当成不可信状态的安全边界。parts采用tuple保留不可变快照,便于教学与测试;反复追加会复制元组,不适合未经测量就处理无限长流。默认10000字符、1000事件是示例政策值,不是任何模型的额度。

accepted_text只检查完整状态。空文本可以在协议层完成,因此本例允许complete得到空字符串;业务要求非空、JSON字段齐全或引用有效时,应另做校验。不要把这个函数当成“内容正确且可自动执行”的证明。

真正的本地取消:cancel_probe.py

仅向归约器传local_cancel并不能测试Task.cancel。下面另建协程,用Event确认已收到半段,然后请求取消、等待退出;正常路径用于对照。finally标记只证明本地清理代码运行过,没有创建网络连接。

"""Actual asyncio cancellation; no sockets, SDKs or model calls."""
import asyncio


async def probe(cancel=True):
    ready, proceed = asyncio.Event(), asyncio.Event()
    result = {"partial": "", "local_status": "running", "closed": False}

    async def worker():
        try:
            result["partial"] += "已收到的半段"
            ready.set()
            await proceed.wait()
            result["partial"] += ",后半段也收到了。"
            result["local_status"] = "complete"
        except asyncio.CancelledError:
            result["local_status"] = "local_cancel"
            raise
        finally:
            result["closed"] = True

    task = asyncio.create_task(worker())
    await ready.wait()  # Synchronization, not a guessed sleep duration.
    if cancel:
        task.cancel()
    else:
        proceed.set()
    try:
        await task
    except asyncio.CancelledError:
        pass  # The owner has awaited cancellation and recorded its status.
    result["task_cancelled"] = task.cancelled()
    result["remote_status"] = "unknown_no_network_in_this_demo"
    return result


if __name__ == "__main__":
    for cancel in (True, False):
        print(asyncio.run(probe(cancel)))

完整回归:test_partial_state.py

import asyncio
import unittest
from partial_state import State, advance
from cancel_probe import probe


def event(kind, text=None, attempt="run-1"):
    e = {"attempt": attempt, "kind": kind}
    if text is not None:
        e["text"] = text
    return e


class StateTests(unittest.TestCase):
    def setUp(self):
        self.initial = State("run-1")
        self.partial = advance(self.initial, event("delta", "你好"))

    def test_complete_accepts_exact_text(self):
        self.assertEqual(advance(self.partial, event("complete")).accepted_text(), "你好")

    def test_noncomplete_never_accepted(self):
        for kind in ("local_cancel", "eof", "transport_error", "rejected"):
            with self.subTest(kind=kind):
                s = advance(self.partial, event(kind))
                self.assertEqual(s.preview, "你好")
                with self.assertRaisesRegex(ValueError, "^incomplete_result$"):
                    s.accepted_text()

    def test_first_terminal_wins(self):
        for first, late in (("complete", "local_cancel"), ("local_cancel", "complete")):
            s = advance(self.partial, event(first))
            with self.assertRaisesRegex(ValueError, "^already_terminal$"):
                advance(s, event(late))
            self.assertEqual(s.phase, first)

    def test_terminal_repeat_idempotent(self):
        s = advance(self.partial, event("complete"))
        self.assertIs(advance(s, event("complete")), s)

    def test_late_delta_rejected(self):
        s = advance(self.partial, event("eof"))
        with self.assertRaisesRegex(ValueError, "already_terminal"):
            advance(s, event("delta", "迟到"))

    def test_wrong_attempt(self):
        with self.assertRaisesRegex(ValueError, "wrong_attempt"):
            advance(self.partial, event("delta", "旧请求", "run-0"))

    def test_exact_char_limit_and_overflow(self):
        s = advance(self.initial, event("delta", "你好"), max_chars=2)
        self.assertEqual(s.chars, 2)
        limited = advance(s, event("delta", "!"), max_chars=2)
        self.assertEqual((limited.phase, limited.preview), ("limit_reached", "你好"))

    def test_event_limit_can_block_completion(self):
        s = advance(self.partial, event("complete"), max_events=1)
        self.assertEqual((s.phase, s.events), ("limit_reached", 1))

    def test_invalid_limits(self):
        for value in (True, 0, -1, "2"):
            with self.assertRaisesRegex(ValueError, "invalid_limit"):
                advance(self.initial, event("complete"), max_events=value)

    def test_invalid_event_shape(self):
        for value in (None, [], {"attempt": "run-1", "kind": "complete", "extra": 1},
                      {"attempt": "run-1", "kind": "delta", "text": 9},
                      {"attempt": "run-1", "kind": []}):
            with self.assertRaises(ValueError):
                advance(self.partial, value)
        self.assertEqual(self.partial.preview, "你好")

    def test_empty_complete_is_transport_not_business_validation(self):
        self.assertEqual(advance(self.initial, event("complete")).accepted_text(), "")

    def test_immutable_snapshot(self):
        later = advance(self.partial, event("delta", "!"))
        self.assertEqual((self.initial.preview, self.partial.preview, later.preview),
                         ("", "你好", "你好!"))


class CancellationTests(unittest.IsolatedAsyncioTestCase):
    async def test_real_task_cancel_propagated(self):
        s = await probe()
        self.assertTrue(s["task_cancelled"])
        self.assertEqual(s["local_status"], "local_cancel")

    async def test_partial_and_finally_preserved(self):
        s = await probe()
        self.assertEqual(s["partial"], "已收到的半段")
        self.assertTrue(s["closed"])

    async def test_normal_completion(self):
        s = await probe(False)
        self.assertEqual(s["local_status"], "complete")
        self.assertFalse(s["task_cancelled"])
        self.assertTrue(s["closed"])


if __name__ == "__main__":
    unittest.main()

运行结果与解释

本次在Python 3.11.9环境实际执行,15项测试通过。运行partial_state.py得到:

complete '半段文字' True
local_cancel '半段文字' False
eof '半段文字' False

运行cancel_probe.py,取消路径返回local_cancel、task_cancelled=True,保留“已收到的半段”;正常路径返回complete、task_cancelled=False。两条路径closed=True;remote_status都明确为unknown_no_network_in_this_demo。这不包含真实网络、中止计费或厂商兼容性测试。

接入顺序与错误排查

先让SDK适配器核对事件种类、响应级状态和拒答/截断,再发规范事件给advance。每次重试使用新的State和attempt。不要从异常分支finally里发送complete;也不要让新请求复用旧请求状态。

ValueError(incomplete_result):上游没有接受完整终态,允许显示带标记的预览,禁止按成品使用。already_terminal:检查是否重复消费者或取消/完成竞态;先保留已冻结状态,调查来源,不自动重置。wrong_attempt:旧请求或路由错误,不能改事件标识来强行接收。invalid_fields/invalid_text:协议适配器违反输入合同,应停止本轮处理并记录错误码,不回显敏感原文。limit_reached:本地政策终止,不能伪装为厂商限流,也不能自动切换成complete。

对真实SDK还要补响应关闭、超时、工具输出、多模态内容、资源释放和持久化策略。取消只说明本地观察结果;远端是否已处理、如何计费,必须查实际服务协议与可用证据,不能由本实验推断。

小练习与下一篇

增加一个测试:先收到两个delta,再收到transport_error,确认preview完整保留而accepted_text仍拒绝;再让相同complete事件重复两次,确认计数不增加。只改本地样本,不发起真实模型请求。

下一篇实现本地并发闸门,解释它和每分钟请求、Token配额的差别。欢迎在评论里给出去掉密钥和个人信息的事件序列,讨论该在哪个状态停止交付。

参考与事实边界

Python 3.11任务取消说明说明取消异常和清理传播;OpenAI流式响应指南将文本增量与响应完成事件分开。前者是本地协程语义,后者是一种厂商协议,不能互相替代。

本文为常青工程教学。10月30日OpenAI公告讨论网站训练偏好信号,原文仍说将在未来数月实施,与本课的流式停止无直接关系,因此没有把它包装为取消能力上新,也未据此声称任何聚合站已支持新能力。没有模型调用、计费实验或性能测量。

内容由AI辅助整理,示例代码经过本地离线验证;上线前仍需按实际SDK、协议和业务要求审查。

深度学习工具包Deprecation notice.-----This toolbox is outdated and no longer maintained.There are much better tools available for deep learning than this toolbox, e.g. [Theano](http://deeplearning.net/software/theano/), [torch](http://torch.ch/) or [tensorflow](http://www.tensorflow.org/)I would suggest you use one of the tools mentioned above rather than use this toolbox.Best, Rasmus.DeepLearnToolbox================A Matlab toolbox for Deep Learning.Deep Learning is a new subfield of machine learning that focuses on learning deep hierarchical models of data.It is inspired by the human brain's apparent deep (layered, hierarchical) architecture.A good overview of the theory of Deep Learning theory is[Learning Deep Architectures for AI](http://www.iro.umontreal.ca/~bengioy/papers/ftml_book.pdf)For a more informal introduction, see the following videos by Geoffrey Hinton and Andrew Ng.* [The Next Generation of Neural Networks](http://www.youtube.com/watch?v=AyzOUbkUf3M) (Hinton, 2007)* [Recent Developments in Deep Learning](http://www.youtube.com/watch?v=VdIURAu1-aU) (Hinton, 2010)* [Unsupervised Feature Learning and Deep Learning](http://www.youtube.com/watch?v=ZmNOAtZIgIk) (Ng, 2011)If you use this toolbox in your research please cite [Prediction as a candidate for learning deep hierarchical models of data](http://www2.imm.dtu.dk/pubdb/views/publication_details.php?id=6284)```@MASTERSTHESIS\{IMM2012-06284, author = "R. B. Palm", title = "Prediction as a candidate for learning deep hierarchical models of data", year = "2012",}```Contact: rasmusbergpalm at gmail dot comDirectories included in the toolbox-----------------------------------`NN/` - A library for Feedforward Backpropagation Neural Networks`CNN/` - A library for Convolutional Neural Networks`DBN/` - A library for Deep Belief Networks`SAE/` - A library for Stacked Auto-Encoders`CAE/` - A library for Convolutional Auto-Encoders`util/` - Utility functions used by the libraries`data/` - Data used by the examples`tests/` - unit tests to verify toolbox is workingFor references on each library check REFS.mdSetup-----1. Download.2. addpath(genpath('DeepLearnToolbox'));Example: Deep Belief Network---------------------```matlabfunction test_example_DBNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit RBM and visualize its weightsrand('state',0)dbn.sizes = [100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);figure; visualize(dbn.rbm{1}.W'); % Visualize the RBM weights%% ex2 train a 100-100 hidden unit DBN and use its weights to initialize a NNrand('state',0)%train dbndbn.sizes = [100 100];opts.numepochs = 1;opts.batchsize = 100;opts.momentum = 0;opts.alpha = 1;dbn = dbnsetup(dbn, train_x, opts);dbn = dbntrain(dbn, train_x, opts);%unfold dbn to nnnn = dbnunfoldtonn(dbn, 10);nn.activation_function = 'sigm';%train nnopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.10, 'Too big error');```Example: Stacked Auto-Encoders---------------------```matlabfunction test_example_SAEload mnist_uint8;train_x = double(train_x)/255;test_x = double(test_x)/255;train_y = double(train_y);test_y = double(test_y);%% ex1 train a 100 hidden unit SDAE and use it to initialize a FFNN% Setup and train a stacked denoising autoencoder (SDAE)rand('state',0)sae = saesetup([784 100]);sae.ae{1}.activation_function = 'sigm';sae.ae{1}.learningRate = 1;sae.ae{1}.inputZeroMaskedFraction = 0.5;opts.numepochs = 1;opts.batchsize = 100;sae = saetrain(sae, train_x, opts);visualize(sae.ae{1}.W{1}(:,2:end)')% Use the SDAE to initialize a FFNNnn = nnsetup([784 100 10]);nn.activation_function = 'sigm';nn.learningRate = 1;nn.W{1} = sae.ae{1}.W{1};% Train the FFNNopts.numepochs = 1;opts.batchsize = 100;nn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.16, 'Too big error');```Example: Convolutional Neural Nets---------------------```matlabfunction test_example_CNNload mnist_uint8;train_x = double(reshape(train_x',28,28,60000))/255;test_x = double(reshape(test_x',28,28,10000))/255;train_y = double(train_y');test_y = double(test_y');%% ex1 Train a 6c-2s-12c-2s Convolutional neural network %will run 1 epoch in about 200 second and get around 11% error. %With 100 epochs you'll get around 1.2% errorrand('state',0)cnn.layers = { struct('type', 'i') %input layer struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %sub sampling layer struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5) %convolution layer struct('type', 's', 'scale', 2) %subsampling layer};cnn = cnnsetup(cnn, train_x, train_y);opts.alpha = 1;opts.batchsize = 50;opts.numepochs = 1;cnn = cnntrain(cnn, train_x, train_y, opts);[er, bad] = cnntest(cnn, test_x, test_y);%plot mean squared errorfigure; plot(cnn.rL);assert(er<0.12, 'Too big error');```Example: Neural Networks---------------------```matlabfunction test_example_NNload mnist_uint8;train_x = double(train_x) / 255;test_x = double(test_x) / 255;train_y = double(train_y);test_y = double(test_y);% normalize[train_x, mu, sigma] = zscore(train_x);test_x = normalize(test_x, mu, sigma);%% ex1 vanilla neural netrand('state',0)nn = nnsetup([784 100 10]);opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samples[nn, L] = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.08, 'Too big error');%% ex2 neural net with L2 weight decayrand('state',0)nn = nnsetup([784 100 10]);nn.weightPenaltyL2 = 1e-4; % L2 weight decayopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex3 neural net with dropoutrand('state',0)nn = nnsetup([784 100 10]);nn.dropoutFraction = 0.5; % Dropout fraction opts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex4 neural net with sigmoid activation functionrand('state',0)nn = nnsetup([784 100 10]);nn.activation_function = 'sigm'; % Sigmoid activation functionnn.learningRate = 1; % Sigm require a lower learning rateopts.numepochs = 1; % Number of full sweeps through dataopts.batchsize = 100; % Take a mean gradient step over this many samplesnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex5 plotting functionalityrand('state',0)nn = nnsetup([784 20 10]);opts.numepochs = 5; % Number of full sweeps through datann.output = 'softmax'; % use softmax outputopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, train_x, train_y, opts);[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');%% ex6 neural net with sigmoid activation and plotting of validation and training error% split training data into training and validation datavx = train_x(1:10000,:);tx = train_x(10001:end,:);vy = train_y(1:10000,:);ty = train_y(10001:end,:);rand('state',0)nn = nnsetup([784 20 10]); nn.output = 'softmax'; % use softmax outputopts.numepochs = 5; % Number of full sweeps through dataopts.batchsize = 1000; % Take a mean gradient step over this many samplesopts.plot = 1; % enable plottingnn = nntrain(nn, tx, ty, opts, vx, vy); % nntrain takes validation set as last two arguments (optionally)[er, bad] = nntest(nn, test_x, test_y);assert(er < 0.1, 'Too big error');```[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/rasmusbergpalm/deeplearntoolbox/trend.png)](https://bitdeli.com/free "Bitdeli Badge")
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值