Python多轮对话messages校验:角色顺序、完整轮次与边界测试

本文介绍一个纯Python实现的多轮对话messages校验器,用于验证角色顺序(system/developer可选前置,后接完整user/assistant轮次,末尾必须为user)、轮次完整性及字符长度边界(101条、20000字符)。校验器基于标准库,返回不可变字符串结果,支持UTF-8序列化,不依赖网络。强调接入前结构检查、裁剪策略与常见错误排查,适用于Chat Completions类API前端预检。

作者说明:本文经AI辅助整理;代码在Python 3.11本地离线检查,不发送网络请求。样本全部为虚构,未做线上模型调用。

昨天我们检查未知请求参数,今天再往里走一层:messages这个字段存在,并不代表消息历史就符合你的应用约定。第一条只剩assistant、末尾还是上次回答、system指令被夹进用户历史,都适合在发送前检查。

本篇交付一个可复制的纯Python校验器与边界测试。它采用明确的项目语法:可选一条system或developer前缀,后面是若干完整user/assistant轮次,最后必须是本轮user输入。

1. 先限定范围

Chat Completions官方参考说明messages记录会话内容,而且消息模态等支持与模型有关。本文语法是教学应用自己的限制,不是官方API只允许这样的序列。连续user消息、工具消息、多模态内容在其他场景可能有意义,本例统一拒绝,防止应用在未实现时直接透传。

输入本例结果理由
user接受一个新的单轮问题
system → user → assistant → user接受前缀、完整历史和当前问题齐全
assistant → user拒绝本例不接受孤立回答开头
user → assistant拒绝缺少本轮待生成的用户问题
user → system → user拒绝不把应用指令混进历史中间
user → user拒绝不符合本例交替语法;不是声称API普遍禁止

system和developer只允许二选一、位于开头,是本例的设计。接入真实模型之前,仍需核对目标角色支持。千万不要为通过校验把不可信用户文本改成system角色。

2. 环境与完整实现

Python 3.11,仅标准库。保存为message_history.py:

"""Project-specific, text-only history grammar; not the full API schema."""
import json


def validate_history(messages, max_messages=101, max_content_chars=20000):
    if type(max_messages) is not int or max_messages < 1:
        raise ValueError("invalid_message_budget")
    if type(max_content_chars) is not int or max_content_chars < 1:
        raise ValueError("invalid_character_budget")
    if type(messages) is not list or not 1 <= len(messages) <= max_messages:
        raise ValueError("invalid_message_count")
    copied = []
    used = 0
    for message in messages:
        if type(message) is not dict or set(message) != {"role", "content"}:
            raise ValueError("invalid_message_shape")
        role, content = message["role"], message["content"]
        if type(role) is not str or role not in ("system", "developer", "user", "assistant"):
            raise ValueError("unsupported_role")
        if type(content) is not str or not content.strip():
            raise ValueError("invalid_text_content")
        used += len(content)
        copied.append({"role": role, "content": content})
    if used > max_content_chars:
        raise ValueError("character_budget_exceeded")
    start = int(copied[0]["role"] in ("system", "developer"))
    if start == len(copied):
        raise ValueError("missing_user_message")
    for index in range(start, len(copied)):
        expected = "user" if (index - start) % 2 == 0 else "assistant"
        if copied[index]["role"] != expected:
            raise ValueError("unexpected_message_order")
    if copied[-1]["role"] != "user":
        raise ValueError("latest_user_required")
    return copied


def encode_history(messages, **budgets):
    checked = validate_history(messages, **budgets)
    return json.dumps(checked, ensure_ascii=False, allow_nan=False).encode("utf-8")


if __name__ == "__main__":
    example = [{"role": "system", "content": "Be concise."},
               {"role": "user", "content": "Draft a notice."},
               {"role": "assistant", "content": "Example draft."},
               {"role": "user", "content": "Keep the date unchanged."}]
    print([message["role"] for message in validate_history(example)])
    try:
        validate_history(example[2:])
    except ValueError as exc:
        print(str(exc))

101条消息、20000内容字符都是本地默认政策,不是模型容量,也不包含消息JSON的封装开销。长度用Python字符串len计数,不当成Token或字节数。

校验函数返回新列表与新字典,内容只允许不可变字符串。调用方随后修改返回对象,不会改掉原列表。encode_history进一步将已校验结果固定成UTF-8字节,序列化步骤本身不会发送请求。

3. 运行示例及实际输出

执行:

python message_history.py

实际本地输出:

['system', 'user', 'assistant', 'user']
unexpected_message_order

第二个样本故意从assistant开始,因此被本地顺序规则拒绝。错误码不拼接消息正文,避免用“方便排错”的理由把用户原文写进日志。

4. 完整测试文件

把下列内容保存为同目录的test_message_history.py:

import json
import unittest
from message_history import validate_history, encode_history


class HistoryTests(unittest.TestCase):
    def setUp(self):
        self.raw = [{"role": "system", "content": "P"}, {"role": "user", "content": "Q"}]

    def test_allowed_sequences(self):
        for prefix in ([], [{"role": "system", "content": "P"}], [{"role": "developer", "content": "P"}]):
            for turns in range(4):
                raw = prefix + [{"role": role, "content": "x"} for role in ["user", "assistant"] * turns + ["user"]]
                with self.subTest(prefix=prefix, turns=turns): self.assertEqual(validate_history(raw), raw)

    def test_bad_order(self):
        for roles in (["assistant", "user"], ["user", "user"], ["user", "assistant"],
                      ["system", "developer", "user"], ["user", "system", "user"]):
            with self.subTest(roles=roles), self.assertRaises(ValueError):
                validate_history([{"role": role, "content": "x"} for role in roles])

    def test_shape_and_unsupported_content(self):
        for raw in ([], {}, [{"role": "tool", "content": "x"}], [{"role": "user", "content": []}],
                    [{"role": "user", "content": " "}], [{"role": "user", "content": "x", "name": "extra"}]):
            with self.subTest(raw=raw), self.assertRaises(ValueError): validate_history(raw)

    def test_exact_limits(self):
        self.assertEqual(validate_history(self.raw, max_messages=2, max_content_chars=2), self.raw)
        for limits in ({'max_messages': 1}, {'max_content_chars': 1}, {'max_messages': True}):
            with self.subTest(limits=limits), self.assertRaises(ValueError): validate_history(self.raw, **limits)

    def test_empty_policy_only(self):
        with self.assertRaisesRegex(ValueError, "missing_user_message"):
            validate_history([{"role": "system", "content": "P"}])

    def test_returned_objects_are_independent(self):
        copied = validate_history(self.raw)
        copied[0]['content'] = 'different'
        self.assertEqual(self.raw[0]['content'], 'P')

    def test_unicode_is_not_tokens(self):
        raw = [{"role": "user", "content": "你好"}]
        self.assertEqual(validate_history(raw, max_content_chars=2), raw)
        self.assertEqual(json.loads(encode_history(raw)), raw)
        self.assertNotEqual(len(encode_history(raw)), 2)


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

执行python -m unittest -v test_message_history。上面7个测试方法包含多种前缀与历史轮数、连续相同角色、错误结尾、额外字段、非文本内容、数量/字符边界、返回对象独立性和中文序列化。

另一个配套的裁剪示例有6个测试方法;本轮合计13个方法通过。这里只验证本地程序规则,不证明真实API一定接受这些角色、历史长短或上下文模式。

5. 接入前怎样使用这层检查

先把应用受控指令、已完成轮次、最新输入分开保存;构造待发列表之后调用validate_history,检查通过再交给发送层。不要从任意前端JSON里直接接受system角色;身份与权限应在更外层由应用确定。

如果需要裁剪,优先按完整轮次裁剪,并在裁剪之后再跑顺序校验。禁止用messages[-N:]后再“补一个空用户消息”凑过测试。缺失的语境并不会因为结构合法而回来。

本例没有数据库、会话并发控制或流式接收逻辑。多标签页同时写历史可能导致交错,项目还需要自己的版本号或并发约束;校验器只能发现部分结构错误,不能替代事务和权限控制。

6. 常见错误怎么排查

  • invalid_message_shape:每条只允许role和content。扩展name、tool_calls等字段前先明确语义与测试,不直接绕过允许列表。
  • unsupported_role:本例不支持tool等角色。需要工具对话时另建语法,不把工具返回冒充普通assistant文本。
  • unexpected_message_order:检查裁剪是否切开了完整轮次,或多个请求是否交错拼接了历史。
  • latest_user_required:确认本轮用户问题确实加入;不要重复发送上次assistant结尾的列表。
  • character_budget_exceeded:这是本地策略超限。需要额外按实际模型资料检查Token预算,而不是把字符阈值解释成官方上限。

7. 今日练习与下一步

给测试增加“同一个输入运行两次不改变原列表”的断言,再设计一条会话:用户在第二轮修正第一轮的日期。裁剪后自己检查修正信息是否还在,不要只检查角色顺序。

下一篇讨论响应usage可能缺失或变化时怎样解析。欢迎在评论区分享一个虚构的顺序错误样本,避免贴真实对话、账号信息或密钥。

资料说明:本篇为常青工程实践。2026年10月26日已检索近24—72小时官方动态;OpenAI 10月23日存储文章属于跨期工程回顾,训练环境文章针对内部研究环境,均不作为本例能力上新依据。会话状态官方指南可用于对照手动管理历史的概念;没有产品推介或注册引流。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值