作者说明:本文经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日存储文章属于跨期工程回顾,训练环境文章针对内部研究环境,均不作为本例能力上新依据。会话状态官方指南可用于对照手动管理历史的概念;没有产品推介或注册引流。
本文介绍一个纯Python实现的多轮对话messages校验器,用于验证角色顺序(system/developer可选前置,后接完整user/assistant轮次,末尾必须为user)、轮次完整性及字符长度边界(101条、20000字符)。校验器基于标准库,返回不可变字符串结果,支持UTF-8序列化,不依赖网络。强调接入前结构检查、裁剪策略与常见错误排查,适用于Chat Completions类API前端预检。
425

被折叠的 条评论
为什么被折叠?



