MyContext 负责上下文拼装、业务侧负责权限过滤与脱敏

文章导读
接入上下文基础设施时,真正容易出错的不是拼装顺序,而是职责边界。建议把 MyContext 的职责收敛为「消息分类、拼装顺序、角色与长度约束」,而权限判断和字段脱敏放在业务侧、在消息写入 MyContext 之前完成。判断标准很直接:凡是需要知道「谁在问、能看哪些行、哪些列要打码」的信息,都不应该进入 MyContext;MyContext 拿到什么就拼什么,它不做二次权限裁剪。
📋 目录
  1. A 列出进入上下文的消息来源
  2. B 在业务侧标出权限过滤发生的位置
  3. C 检查 MyContext 拼装后的上下文是否残留敏感字段
  4. D 用边界用例确认职责不重叠
A A

接入上下文基础设施时,真正容易出错的不是拼装顺序,而是职责边界。建议把 MyContext 的职责收敛为「消息分类、拼装顺序、角色与长度约束」,而权限判断和字段脱敏放在业务侧、在消息写入 MyContext 之前完成。判断标准很直接:凡是需要知道「谁在问、能看哪些行、哪些列要打码」的信息,都不应该进入 MyContext;MyContext 拿到什么就拼什么,它不做二次权限裁剪。

这样拆的代价是业务侧要维护一份字段级过滤规则,改一次规则可能要同步改多个调用点,需要接受这部分重复工作量。换来的是可验证性:过滤放在业务侧,日志里能逐条对上原始字段和过滤后字段;放在拼装层,则要看拼装实现的内部状态,出问题时很难界定是权限没判对还是拼装把旧值带进来了。

MyContext 的边界建议定为:消息分类、拼装顺序、长度与角色约束。业务侧在调用 MyContext 写入接口之前完成权限过滤与脱敏,写入的必须已经是可安全出现在上下文里的内容。验证方式是比对写入前后的字段快照,并对跨租户、无权限、工具结果含隐私三类样例逐层记录处理结果。不要把权限判断放进上下文层,否则敏感字段会随历史消息或工具结果在后续轮次中反复留存。

列出进入上下文的消息来源

先把可能进入 MyContext 的内容列全,再按字段而不是按整条消息判断敏感等级。整条消息定为「低敏」是最常见的误判来源,因为一条系统指令里可能被模板插值了用户数据,一条历史消息里可能夹带上游未过滤的原始值。

来源分类典型内容敏感等级来源位置过滤责任方
用户输入提问文本、上传内容的解析结果中到高会话入口业务侧,写入前
工具结果数据库行、接口返回、文件片段高,可能含身份字段与密钥工具调用的返回处业务侧,工具封装层
历史消息前若干轮问答、人工回填记录中,可能含过滤前的原始值会话存储的读取处业务侧,回放时再校验一次
系统指令角色设定、格式约束、工具说明低,但插值后升高服务配置与模板文件平台侧,禁止在指令模板里插用户数据

建议每条消息都带一个 source 标记,例如 user.input、tool.db.query、history.replay、system.prompt。这个标记不是为了好看,而是后面排查时能把日志按来源对齐:上下文里出现异常字段时,能立刻定位是哪条链路漏了过滤。

MyContext 负责上下文拼装、业务侧负责权限过滤与脱敏

在业务侧标出权限过滤发生的位置

权限过滤建议只在一处做:业务侧构造待写入消息的那个函数。工具结果在工具封装层过滤,历史消息在回放层过滤,两处都产出同一个结构,再交给 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 负责上下文拼装、业务侧负责权限过滤与脱敏

检查 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 里补一层字段擦除看起来省事,但会让权限逻辑和上下文逻辑混在一个地方,后续没人说得清某次删字段到底是权限决定还是拼装副作用。

MyContext 负责上下文拼装、业务侧负责权限过滤与脱敏

用边界用例确认职责不重叠

把下面三类样例固化进测试或手工验证流程,每层各记一条结构化日志,字段建议包含 request_id、layer、action、field,用同一个 request_id 串起来。这样出问题时能按层归因,而不是只看到最后的上下文。

样例输入业务侧期望MyContext 期望判定标准
跨租户会话租户为 A,工具返回租户 B 的一行整行丢弃,返回空结果只看到过滤后的空结果上下文里不出现 B 的任何字段值
无权限列当前角色无某列权限该列删除或置空该字段不存在MyContext 不补默认值,不补占位符
工具结果含隐私返回值含手机号、证件号掩码或删除后再写入只拼装已处理内容扫描不到敏感键名与原始值

三类样例跑完后,逐层记录:工具封装层做了什么动作、业务服务层做了什么动作、MyContext 写入处收到的是什么。如果某一层的记录里出现了本不该由它处理的字段,就说明职责重叠了。

发现 MyContext 里存在权限相关分支时,建议按「先加日志、再补白名单过滤、最后清理拼装层判断」的顺序改,一次只动一层,改完立刻用上面的扫描和三类样例回归。改动范围小,出问题时也能很快定位到是哪一层的规则没同步。