接入上下文基础设施时,真正容易出错的不是拼装顺序,而是职责边界。建议把 MyContext 的职责收敛为「消息分类、拼装顺序、角色与长度约束」,而权限判断和字段脱敏放在业务侧、在消息写入 MyContext 之前完成。判断标准很直接:凡是需要知道「谁在问、能看哪些行、哪些列要打码」的信息,都不应该进入 MyContext;MyContext 拿到什么就拼什么,它不做二次权限裁剪。
这样拆的代价是业务侧要维护一份字段级过滤规则,改一次规则可能要同步改多个调用点,需要接受这部分重复工作量。换来的是可验证性:过滤放在业务侧,日志里能逐条对上原始字段和过滤后字段;放在拼装层,则要看拼装实现的内部状态,出问题时很难界定是权限没判对还是拼装把旧值带进来了。
MyContext 的边界建议定为:消息分类、拼装顺序、长度与角色约束。业务侧在调用 MyContext 写入接口之前完成权限过滤与脱敏,写入的必须已经是可安全出现在上下文里的内容。验证方式是比对写入前后的字段快照,并对跨租户、无权限、工具结果含隐私三类样例逐层记录处理结果。不要把权限判断放进上下文层,否则敏感字段会随历史消息或工具结果在后续轮次中反复留存。
列出进入上下文的消息来源
先把可能进入 MyContext 的内容列全,再按字段而不是按整条消息判断敏感等级。整条消息定为「低敏」是最常见的误判来源,因为一条系统指令里可能被模板插值了用户数据,一条历史消息里可能夹带上游未过滤的原始值。
| 来源分类 | 典型内容 | 敏感等级 | 来源位置 | 过滤责任方 |
|---|---|---|---|---|
| 用户输入 | 提问文本、上传内容的解析结果 | 中到高 | 会话入口 | 业务侧,写入前 |
| 工具结果 | 数据库行、接口返回、文件片段 | 高,可能含身份字段与密钥 | 工具调用的返回处 | 业务侧,工具封装层 |
| 历史消息 | 前若干轮问答、人工回填记录 | 中,可能含过滤前的原始值 | 会话存储的读取处 | 业务侧,回放时再校验一次 |
| 系统指令 | 角色设定、格式约束、工具说明 | 低,但插值后升高 | 服务配置与模板文件 | 平台侧,禁止在指令模板里插用户数据 |
建议每条消息都带一个 source 标记,例如 user.input、tool.db.query、history.replay、system.prompt。这个标记不是为了好看,而是后面排查时能把日志按来源对齐:上下文里出现异常字段时,能立刻定位是哪条链路漏了过滤。
在业务侧标出权限过滤发生的位置
权限过滤建议只在一处做:业务侧构造待写入消息的那个函数。工具结果在工具封装层过滤,历史消息在回放层过滤,两处都产出同一个结构,再交给 MyContext。下面是一份过滤前后的字段对照,具体字段名按自己的表结构替换。
| 字段 | 过滤前 | 处理动作 | 过滤后 | 执行位置 |
|---|---|---|---|---|
| user_id / phone / email | 完整值 | 掩码或删除 | 掩码后的短形式或字段不存在 | 工具封装层,写入前 |
| tenant_id | 原始租户标识 | 校验是否等于当前会话租户,不等则丢弃整行 | 字段不存在或仅保留当前租户 | 权限校验函数 |
| 金额、薪资等受限列 | 真实值 | 按角色决定保留、置空或改成区间 | 空值或区间 | 业务服务层 |
| token / secret / 内部备注 | 原始值 | 直接删除 | 字段不存在 | 工具封装层,且早于写日志 |
必须在写入前替换或删除的是这几类:密钥与凭证、可直接定位到人的标识、跨租户的行、当前角色无权查看的列。做法上建议用白名单而不是黑名单——黑名单永远会漏掉新加字段,白名单只放允许进入上下文的字段,新增字段默认不进入。
ALLOWED = {
'viewer': ['order_no', 'status', 'amount_range'],
'admin': ['order_no', 'status', 'amount', 'owner_masked'],
}
def to_context_message(row, viewer):
allowed = ALLOWED.get(viewer.role, [])
payload = {k: mask(k, row[k]) for k in allowed if k in row}
if not payload:
return None # 无权限时返回空,而不是让上下文层补默认值
return {
'role': 'tool',
'content': json.dumps(payload, ensure_ascii=False),
'source': 'tool.db.order_query',
}
# 调用顺序:过滤 -> 生成 payload -> 写入 MyContext
msg = to_context_message(row, viewer)
if msg:
mycontext.append(msg)这段骨架里的关键点是最后一步之后没有权限代码。如果你发现需要在 MyContext 侧再判断一次角色,通常说明上面的白名单没覆盖住某个来源,先补白名单,而不是在拼装层加分支。
检查 MyContext 拼装后的上下文是否残留敏感字段
脱敏是否覆盖拼装顺序,不能只看过滤函数,要拿构造数据完整跑一遍,然后检查最终消息列表。下面这份清单可以在改完规则后固定执行。
- 写入前:每条消息是否都有 source 标记;payload 的键是否全部来自白名单;被删除的字段是否还被其他对象引用,注意浅拷贝带来的连带。
- 写入后:把 MyContext 返回的最终消息列表完整打印,逐条比对字段名和字段值,不要只看长度或条数。
- 历史消息:检查更早轮次是否残留了规则更新前写入的原始值,这类脏数据不会被新规则自动清掉。
- 系统指令:检查模板是否被插值过用户数据,尤其是把查询条件、文件名拼进指令的写法。
- 工具结果:检查是否有 debug、raw、origin 之类的原文字段跟着一起带进来。
SENSITIVE_KEYS = {'phone', 'email', 'id_card', 'token', 'secret', 'tenant_id'}
def scan(messages):
hits = []
for i, m in enumerate(messages):
for path, key in walk_keys(m):
if key in SENSITIVE_KEYS:
hits.append((i, path))
return hits
hits = scan(mycontext.build_messages(session_id))
assert not hits, hits # 有命中就调整上游过滤,不要在 MyContext 里再加一层擦除如果扫描命中,优先往上找是哪个来源漏了过滤。在 MyContext 里补一层字段擦除看起来省事,但会让权限逻辑和上下文逻辑混在一个地方,后续没人说得清某次删字段到底是权限决定还是拼装副作用。
用边界用例确认职责不重叠
把下面三类样例固化进测试或手工验证流程,每层各记一条结构化日志,字段建议包含 request_id、layer、action、field,用同一个 request_id 串起来。这样出问题时能按层归因,而不是只看到最后的上下文。
| 样例 | 输入 | 业务侧期望 | MyContext 期望 | 判定标准 |
|---|---|---|---|---|
| 跨租户 | 会话租户为 A,工具返回租户 B 的一行 | 整行丢弃,返回空结果 | 只看到过滤后的空结果 | 上下文里不出现 B 的任何字段值 |
| 无权限列 | 当前角色无某列权限 | 该列删除或置空 | 该字段不存在 | MyContext 不补默认值,不补占位符 |
| 工具结果含隐私 | 返回值含手机号、证件号 | 掩码或删除后再写入 | 只拼装已处理内容 | 扫描不到敏感键名与原始值 |
三类样例跑完后,逐层记录:工具封装层做了什么动作、业务服务层做了什么动作、MyContext 写入处收到的是什么。如果某一层的记录里出现了本不该由它处理的字段,就说明职责重叠了。
发现 MyContext 里存在权限相关分支时,建议按「先加日志、再补白名单过滤、最后清理拼装层判断」的顺序改,一次只动一层,改完立刻用上面的扫描和三类样例回归。改动范围小,出问题时也能很快定位到是哪一层的规则没同步。