ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ZDR实战:Responses API、MCP与AI Agent留存边界

ZDR实战:Responses API、MCP与AI Agent留存边界

提示:文中涉及的接口字段、开关名称与保留窗口,均以当前官方文档和实际合同条款为准,版本迭代较快,落地前务必做一次核对。

1. 先厘清一个常见误解:ZDR 提供的究竟是什么保障

前阵子帮一个做智能客服的团队做架构评审,会上有位同学拍了胸脯说“我们走的是 ZDR,数据不留,放心”。结果我把他们的链路图往白板上一画——模型调用、Responses API、两个 MCP 工具、一个自建向量库、一套可观测性平台——他自己也沉默了。因为 ZDR 覆盖的只是其中一小段,剩下的每一段都可能悄悄留下副本。这类误解非常普遍,所以这篇就把OpenAI Zero Data Retention(零数据留存,下文简称 ZDR)和Responses API、MCP、AI Agent这几个概念放在同一条链路上拆开讲,说清楚边界到底画在哪里、哪些地方必须自己补位。

这篇适合三类人看:正在给企业客户做 AI 应用交付、被数据治理条款卡过的工程师;正在设计 Agent 架构、需要提前把留存面规划进去的架构师;以及负责安全合规、需要一份可执行核对清单的同学。全文不讲空话,尽量给到可以直接照着改的配置、验证方法和踩坑记录。

1.1 ZDR 的适用对象与开通前提

ZDR 不是你在代码里加个参数就能生效的能力,它本质上是账号层面的一项数据治理安排。通常需要企业级账号、经过商务与安全评估、在合同或数据处理附录里明确写入,然后在组织级别打开。也就是说,它是组织属性,不是单次请求属性。这一点很关键:很多团队以为调用时传一个开关就等于零留存,实际上组织没开通,那个开关能起的作用有限。

从工程视角看,ZDR 承诺的核心是:请求内容和模型输出不会被持久化保存用于后续用途,也不会被拿去做模型训练。但要注意,“不用于训练”和“不留存”是两件独立的事。前者是数据用途开关,后者是数据生命周期策略。你可能同时需要两者,但它们是两个不同的配置项,别混为一谈。

另外一个行业通行做法是:即便在零留存安排下,出于安全与滥用监控目的,仍可能保留一个短周期的审查窗口,窗口内的记录通常不对外提供、只用于安全审查,到期自动清理。具体天数各家条款不同,落地时以合同为准。所以内部沟通时,建议把话说成“业务数据不进长期存储”,而不是“一个字节都不落盘”,后者容易给自己挖坑。

1.2 四个高频混淆概念的对照

做方案评审时,我发现大家经常把下面四件事说成一件事。用一张表拉开差距,后面讨论会顺畅很多。

概念解决的问题典型落地位置常见误解
不用于训练数据用途边界组织级策略 + 合同以为等于不存储
零数据留存 ZDR数据生命周期组织级开通 + 请求参数配合以为开了就全链路干净
数据驻留数据物理位置区域化部署选项以为驻留等于不留存
审计日志可追溯性应用侧日志系统以为日志不算“留存”

最后一行是最容易翻车的。很多团队把 ZDR 做完了,转头在应用侧把完整请求体打进日志平台,保留 180 天,还做了全文检索。从数据治理的视角看,这跟没做 ZDR 差别不大——敏感内容只是换了个地方躺着。

2. Responses API 的有状态设计,为什么让留存问题变复杂

Responses API 相比早期的纯无状态接口,最大的变化是引入了服务端会话对象。你可以只回传一个previous_response_id,让服务端自己把上下文接上,省掉客户端拼接历史消息的麻烦。这个设计对多轮 Agent 非常友好,但它天然意味着服务端要存东西。这跟 ZDR 就是一对需要显式调和的矛盾。

2.1 store 参数的真实语义

在 Responses 请求里,store决定这次响应对象是否在服务端保留。默认值在不同版本和不同账号类型下可能不同,所以不要靠记忆,直接在请求里显式写死。我的习惯是:所有请求一律显式声明store,绝不依赖默认值。理由很简单,默认值会随版本演进,而显式的字段是代码评审时能被看见的。

在已开通 ZDR 的组织下,通常会出现两种表现:一种是请求被强制按不存储处理,你传store: true也不生效;另一种是直接返回参数错误,提示该组织不支持存储。两种都算正常,但排查思路完全不同。所以第一件事是拿一个最小请求做探针,确认你的组织落在哪种行为上,再决定代码怎么写。

2.2 两种调用姿势的取舍

关闭服务端存储之后,多轮上下文得自己管。这里给两段对比代码,注意看差异点。

# 姿势 A:依赖服务端会话(非 ZDR 场景常见写法) from openai import OpenAI client = OpenAI() first = client.responses.create( model="<model-name>", input="帮我总结这段工单内容:……", store=True, ) second = client.responses.create( model="<model-name>", input="把上面的结论压缩成三条", previous_response_id=first.id, store=True, )
# 姿势 B:ZDR 下的自管理上下文 from openai import OpenAI client = OpenAI() history = [] def ask(user_text: str) -> str: history.append({"role": "user", "content": user_text}) resp = client.responses.create( model="<model-name>", input=history, store=False, ) history.append({"role": "assistant", "content": resp.output_text}) # 关键:客户端侧也要有截断策略,否则上下文无限膨胀 if len(history) > 20: del history[:2] return resp.output_text

姿势 B 换来的是留存面收敛,代价有三个:token 成本上升(每轮都要重传历史)、首字延迟略增、上下文裁剪逻辑得自己写而且容易写出 bug。我在一个项目里就因为裁剪逻辑把 system 提示词一起删了,导致模型行为漂移了两天才被发现。

对比维度服务端会话自管理上下文
服务端留存面有(响应对象)无(仅请求期)
每轮 token 成本低高,随轮次线性增长
上下文可控性弱强,可精确裁剪
客户端实现复杂度低中高
适合场景低敏感、追求体验高敏感、需审计

2.3 后台任务与异步模式要单独评估

Responses 支持把长任务放到后台跑,任务状态需要在服务端维护一段时间,客户端再回来取结果。这个能力在 ZDR 场景下要格外小心:任务状态的持久化本身就是一种留存。我的建议是,凡是涉及后台模式的链路,单独拉一个清单出来,逐条确认任务对象里存了什么、存多久、能否在完成后立即删除。如果业务上只是偶尔需要跑长任务,宁可走同步流式 + 客户端重试,也不要为了省事把整条链路切到后台模式。

3. MCP 是数据出站的第一道缺口

MCP 这套开放协议解决的是“模型怎么标准化地调用外部工具和数据源”的问题。它的价值很明确:工具描述、参数结构、调用结果都有了统一约定,Agent 的编排层不用为每个工具写一套适配。但它同时把一条数据出站通道摆在了你面前,而这条通道不在 ZDR 的覆盖范围内。

3.1 一条完整的调用链路长什么样

一次 MCP 工具调用大致经过这些环节:模型生成工具调用意图,编排层解析成结构化请求,MCP 客户端通过 stdio 或 HTTP 把请求发给 MCP 服务端,服务端再去访问真实的外部系统(数据库、工单系统、搜索服务),返回结果沿原路回传,进入模型上下文。整条链路上,ZDR 只能约束“模型服务侧不长期保存”,而 MCP 服务端那一跳,是你自己或者第三方在收数据。

我见过最典型的场景是:客服 Agent 调用一个自建的订单查询工具,用户随口说了句“我的手机号是……”,这个号码作为工具参数被发到 MCP 服务端,而那个服务端的访问日志默认开着、保留 90 天、还带请求体。模型侧干干净净,业务侧一地鸡毛。

3.2 三类 MCP 服务端的风险分级

类型部署位置主要风险建议
本地 stdio 工具与应用同机本地日志文件、临时文件残留关闭 debug 日志,临时文件用后即删
自建 HTTP 工具自有内网访问日志、链路追踪、参数落库日志脱敏 + 短保留 + 字段白名单
第三方托管工具外部服务数据出境、条款不透明、二次利用逐家做安全评估,能不传就不传

第三类最需要警惕。因为它的数据处理条款你控制不了,而 ZDR 也管不到它。评审时我会直接问一句:这个工具的服务方,能不能书面说明参数不做持久化?答不上来的,一律按“会留存”处理,然后看业务能不能接受。

3.3 在 MCP 前面加一层数据最小化代理

实操上最有效的做法不是禁用工具,而是在编排层和 MCP 客户端之间插一层很薄的代理,做三件事:字段白名单、值脱敏、调用审计。伪代码如下。

ALLOWED_FIELDS = { "query_order": {"order_id", "date_range"}, "search_kb": {"keyword", "top_k"}, } def sanitize(tool_name: str, args: dict) -> dict: allowed = ALLOWED_FIELDS.get(tool_name, set()) cleaned = {k: v for k, v in args.items() if k in allowed} # 对自由文本做长度截断与敏感模式替换 for k, v in cleaned.items(): if isinstance(v, str) and len(v) > 200: cleaned[k] = v[:200] return cleaned def call_tool(tool_name: str, raw_args: dict): safe_args = sanitize(tool_name, raw_args) audit_log(tool=tool_name, keys=list(safe_args.keys())) # 只记键名,不记值 return mcp_client.invoke(tool_name, safe_args)

这段代码的价值不在于多精巧,而在于它把“什么能出站”变成了一份显式清单。清单进代码库、进评审流程,比写在文档里靠谱得多。

3.4 别忽略工具返回值的反向污染

大家盯着出站参数,往往忘了入站结果。工具返回的内容会进入模型上下文,如果这次请求恰好开了存储,那工具查出来的订单详情、客户备注就一起被存进去了。更麻烦的是,这些内容还会被后续轮次反复带上。所以工具返回值也要做裁剪:只回传模型决策必需的最小字段,别把整行数据库记录直接丢进上下文。

4. AI Agent 的留存面全景盘点

单个接口的留存好控制,一旦变成多轮、多工具、带记忆的 Agent,留存点会散落在五六个地方。我一般用一张表把全部落点列出来,逐个标“已处理 / 待处理 / 不适用”,评审时对着过。

4.1 六个必须逐个确认的落点

序号落点存了什么归谁管常见疏漏
1模型请求与响应完整上下文服务方 + 合同以为 ZDR 全包
2响应对象会话历史服务方忘了 store 参数
3Agent 记忆 / 向量库用户偏好、历史摘要自己无 TTL、无分区分权
4工具调用日志参数与返回值自己或第三方默认全量记录
5可观测性与追踪完整 prompt 与 tool 事件自己调试期开启后忘了关
6前端会话缓存对话内容自己存 localStorage 且不清理

第 5 项是我见过翻车最多的。为了排查一个线上问题,临时把追踪级别调到最细,问题解决了没人改回来,结果一周内所有用户的完整对话都进了追踪平台。这跟 ZDR 毫无关系,纯粹是工程纪律问题。

4.2 记忆与向量库:最容易被忽视的第二数据库

Agent 一旦带了长期记忆,向量库就成了第二个用户数据库。Embedding 本身也是数据,而且是更难删除的数据——你很难从一堆稠密向量里精确定位“这个用户的那句话”。实操建议有三条:按租户或用户做物理分区,便于整分区删除;给每条记忆打时间戳并设置 TTL,到期自动清理;写入前做一次敏感信息过滤,别把手机号、证件号这类字段编进向量。

4.3 编排框架的默认日志级别

主流编排框架在不配置的情况下,往往会输出相当详细的运行日志,包括完整提示词和工具入参出参。这些日志默认打到标准输出,容器平台一采集就进了日志中心。我的做法是在启动脚本里显式关掉追踪开关,并把日志级别固定为信息级,同时在代码仓库里加一条检查规则,防止有人为了调试把开关又打开提交上去。

5. 落地一套可审计的链路:从画边界开始

讲了这么多风险,落到工程上其实就三件事:把信任边界画清楚、把配置写死、把验证做成例行检查。

5.1 信任边界的画法

我习惯在架构图上用一条线把系统切成两半:线的一侧是“我完全控制”的组件,另一侧是“我部分控制或完全不控制”的组件。对 AI 应用来说,这条线通常画在编排层和外部服务之间。线右侧包括模型服务、第三方 MCP 工具、第三方追踪平台;线左侧包括自己的业务后端、自建工具、自建存储。ZDR 只管右侧第一格,其余每一格都要单独给出控制手段。这个动作看起来简单,但它能一次性把“我们以为安全”和“实际安全”之间的差距暴露出来。

5.2 一份可以直接抄的配置清单

组件配置项建议值验证方式
模型请求store显式 false抓包看请求体
模型请求会话续接禁用 previous_response_id试探应报错
长任务后台模式默认关闭代码检索关键字
MCP 客户端参数白名单按工具配置单测覆盖
MCP 服务端访问日志仅记元数据抽查日志样本
编排框架追踪开关生产关闭启动脚本检查
应用日志请求体记录禁止日志采样审计
向量库TTL按业务设 7 至 30 天定时任务监控

5.3 怎么证明“确实没留”

这一步最容易被跳过,也最值得做。我一般用四个动作交叉验证。第一,抓一次真实请求的报文,确认请求体里store字段的值符合预期。第二,用上一次响应的标识去做续接试探,如果组织处于零留存模式,这个调用应该失败或者被降级,失败本身就是证据。第三,从日志平台随机抽十条记录,看有没有完整请求体,这一条最能暴露工程纪律问题。第四,每季度做一次“红队式”抽查:让一位不熟悉链路的同学,只看代码和配置,试着找出三个可能留存用户原文的地方。找得到就修,找不到说明设计确实收敛了。

# 最小探针:确认组织行为,注意替换占位符 curl -s https://api.openai.com/v1/responses \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "<model-name>", "input": "ping", "store": false }' | head -c 500

跑完这个探针,重点看返回体里跟存储状态相关的字段,以及有没有告警提示。不同版本字段命名会有差异,别硬记字段名,看返回结构里的语义即可。

6. 排查实录:那些让人抓头的报错与异常

下面这些是我和同事在真实项目里遇到过的现象,按“现象—原因—处理”整理,遇到类似情况可以先对照一遍。

现象可能原因处理方式
传 store 为真但行为像关闭组织已启用零留存,参数被覆盖改为显式 false,避免误导后来者
续接调用返回参数错误该组织不允许服务端会话切换到自管理上下文
工具调用偶发超时后重复执行客户端重试策略与工具幂等性冲突加幂等键,重试只做查询类
流式响应中断产生半截内容网络抖动,客户端未做完整性校验加结束标记校验,失败重放
日志平台出现完整对话调试开关未复位纳入上线检查清单
向量库体积异常增长缺少 TTL 与去重定时清理 + 写入前去重

有几条经验值得单独说。重试与幂等这一条,很多团队在查询类工具上不设幂等键,超时重试就把同一条记录查了三次,日志里也写了三遍。虽然数据没被模型侧留存,但工具侧留了三份。流式中断这一条更隐蔽,客户端拿到半截 JSON 就直接解析,解析失败的异常里往往带着原始响应片段,一旦被打进错误日志,又是一次留存。

还有一个高频问题:多环境配置漂移。测试环境为了调试把追踪开满,生产环境用的是同一份配置文件模板,部署时忘了覆盖。我的做法是把“是否允许记录完整请求体”做成一个显式的环境变量,默认值为禁止,只有在本地开发环境才手动改成允许,并且这个变量名里带上dev_only字样,让它在代码评审时足够扎眼。

7. 成本、体验与安全的三角平衡

把留存面收紧之后,最直接的两个代价是 token 成本和交互体验。这一节聊怎么把代价控制在可接受范围。

7.1 自管理上下文的账单怎么算

举个具体例子。假设一轮对话历史平均 800 token,用户会聊 10 轮,输出平均 300 token。走服务端会话时,每轮只需传新增内容,10 轮累计输入大约 8000 token 量级;走自管理上下文时,第 N 轮要重传前 N-1 轮,累计输入接近 44000 token 量级,差了五倍多。这个差距在低单价模型上还能忍,在高单价模型上就是实打实的预算问题。

优化手段有三个:把历史做摘要压缩,只保留关键事实而不是原话;对早期轮次做滑动窗口,超过窗口的直接丢弃;把不敏感的短会话路由到服务端会话模式,敏感会话走自管理。第三条需要一套路由判断逻辑,通常按会话来源或用户等级来分,实现成本不高但收益明显。

7.2 分级策略比一刀切更现实

我越来越倾向于按数据敏感度做分级,而不是全链路一刀切。第一级是公开信息类场景,比如产品文档问答,这类完全可以走服务端会话,体验优先;第二级是内部业务数据,走自管理上下文加日志脱敏;第三级是涉及个人身份信息的场景,除了上述措施,还要加上工具参数白名单、向量库分区、以及更短的日志保留窗口。分级的好处是资源花在刀刃上,坏处是需要一套判定规则并且要防止误判。我的做法是默认落到最高级别,只有明确的场景白名单才允许降级,这样出错时是“过度保守”而不是“意外泄露”。

7.3 一个容易被忽略的成本项:验证本身的开销

做验证要发探针请求、要跑抽检脚本、要做日志采样,这些都会产生费用和人力。我一般把验证频率定成:探针请求每次发布跑一次,日志抽检每周一次,红队式抽查每季度一次。频率再高收益递减,频率再低容易积压问题。这套节奏跑下来,一个中等规模的团队每周投入不到两小时,但能挡住绝大部分配置漂移。

最后分享一个我自己踩过的坑。早期做零留存改造时,我把注意力全放在请求参数上,觉得store写死就万事大吉。直到有一次线上排查,需要还原一个用户的完整对话,我在日志平台里搜关键词,三秒钟就搜出了原文——那一刻才意识到,真正的留存风险从来不在模型服务那一侧,而在我们自己为了方便而随手打开的每一个开关里。后来我们定了一条内部规矩:任何会记录完整请求体的改动,必须走一次额外的评审,评审只问一句话——这个日志,三个月后还敢不敢给客户看。答不上来,就别记。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进