Agent Development Kit (ADK) · 17 - Artifact

Posted on Sep 23, 2026

ADK-Go 源码阅读笔记 · Artifact:文件版本、作用域与事件摘要

源码基准:ADK-Go v2.4.0,commit 4e57df40cbd35f055ba654b03ffb0e509fdd754a。本文的“当前实现”均指这个版本。

本文范围:公开 artifact.Service 契约、请求校验、InMemoryService、GCS 实现,以及 Runner 如何把它们接到 agent.Context。Session/Event 中的 ArtifactDelta 只作为“版本摘要”解释,不展开整个 Session Service 的实现。

返回 ADK 源码阅读合集 · 配套阅读:Context、Runner、Session & Event、Tool。

用户上传一张天气截图,工具把它保存成 report.txt,随后又生成一版带结论的文件。应用需要回答几个看似简单的问题:第二次保存是不是覆盖第一次,下一次会话能不能看到同一个文件,Event 里的 ArtifactDelta 是否就是文件内容,以及 GCS 返回的 URI 能不能直接拿来下载?

先给结论:Artifact Service 保存的是带版本号的 genai.Part,版本键由 app/user/session/filename 组成;agent.Artifacts 只是把当前 Session 的身份填进请求;ArtifactDelta 只记录“某文件成功保存到了哪个版本”,并不代替保存,也不包含 payload。 InMemory 和 GCS 遵守同一个六方法接口,但版本竞争、返回的 Part 形态和元数据能力不同。

本文用同一组身份贯穿:AppName=weather、UserID=u-1、SessionID=s-1。先保存两次 report.txt,再读最新版本、指定版本和元数据;第四节把文件改名为 user:profile.json,并把后端换成 GCS,观察作用域和并发分支。

阅读前先分清这些词

术语本文中的准确含义不要混成什么
Artifact一个 genai.Part 指针及其文件名、版本和存储身份Session Event 本身;Event 只可携带版本摘要
artifact.Service接受 AppName/UserID/SessionID/FileName 请求的六操作存储契约agent.Artifacts 这个给 Context 使用的薄适配器
版本 0在 Load/GetArtifactVersion 中表示“取最新”,在 Delete 中表示“删全部”一个实际存储版本;实际版本从 1 开始
user: 文件名触发跨 Session 的用户作用域文件系统路径;路径分隔符仍会被拒绝
ArtifactDeltafilename -> version 的事件动作字段保存操作、payload 或事务日志
CanonicalURI后端原生方案的对象身份,例如 gs://…已认证的 HTTP 下载地址

一、源码入口与阅读地图

Artifact 的入口不在 Runner,而在顶层 artifact 包。Runner 只负责把当前 Session 的身份装配进内部适配器;Context 和工具看到的是 agent.Artifacts,不是 artifact.Service 的请求结构。

阅读顺序文件 / 符号需要回答的问题
1. 公开契约artifact/service.go#L31-L51六个操作分别返回什么?
2. 请求与元数据artifact/service.go#L59-L123、#L125-L162、#L164-L322哪些字段必填,版本 0 怎样解释?
3. Context 适配器internal/artifact/artifacts.go#L26-L71agent.Artifacts 如何补齐身份?
4. Runner 装配runner/run_node.go#L283-L313谁创建这个适配器,什么时候可能是 nil?
5. 本地实现artifact/inmemory.go#L34-L163、#L165-L327版本和用户作用域怎样编码?
6. GCS 实现artifact/gcsartifact/service.go#L59-L170、#L238-L505并发写入、对象路径和元数据怎样处理?
7. 事件边界agent/common_context.go#L87-L155、#L464-L495保存成功后谁写入 ArtifactDelta?
Runner
  └─ current Session: app / user / session
       │
       ▼
internal/artifact.Artifacts
  ├─ Save(name, Part)      ─┐
  ├─ Load(name[, version])  │  填入四个身份字段
  └─ List()                ─┘
       │
       ▼
artifact.Service
  ├─ InMemoryService     → 有序复合键 + Part 指针
  └─ gcsartifact.Service → GCS 对象 + 版本前置条件
       │
       └─ 成功 Save 返回 version
              │
              ▼
       tracked Context(若当前 wrapper 启用)
              │
              ▼
       EventActions.ArtifactDelta[name] = version

internal/artifact 是 Go 的 internal 包,只能由模块内部导入。应用侧通常拿到的是 agent.Context,通过 ctx.Artifacts() 调用;真正的存储后端由 Runner 配置注入。

二、核心接口与数据结构

1. Service 的六个操作

源码中的公共接口很小,但每个方法对版本 0 的解释不同。下面是 artifact.Service 的源码摘录:

type Service interface {
    Save(ctx context.Context, req *SaveRequest) (*SaveResponse, error)
    Load(ctx context.Context, req *LoadRequest) (*LoadResponse, error)
    Delete(ctx context.Context, req *DeleteRequest) error
    List(ctx context.Context, req *ListRequest) (*ListResponse, error)
    Versions(ctx context.Context, req *VersionsRequest) (*VersionsResponse, error)
    GetArtifactVersion(ctx context.Context, req *GetArtifactVersionRequest) (*GetArtifactVersionResponse, error)
}
操作输入核心字段成功结果Version=0 的意义
Save四个身份字段 + Part新版本号当前两个内置实现都会自动分配下一版本
Load四个身份字段,可选版本完整 genai.Part读取最新版本
Delete四个身份字段,可选版本error删除该文件的全部版本
ListAppName/UserID/SessionID文件名列表不适用
Versions四个身份字段版本号列表不适用
GetArtifactVersion四个身份字段,可选版本元数据结构返回最新版本的元数据

Delete 的接口注释规定删除不存在的条目不应报错;具体后端仍需把这个契约落实到自己的对象删除调用上。读取、列版本和读取元数据在找不到对象时都应让调用方能用 errors.Is(err, fs.ErrNotExist) 判断。

2. 请求校验不是装饰性检查

所有请求实现都先调用自己的 Validate。AppName、UserID、SessionID 和 FileName 按顺序检查非空;SaveRequest 还要求 Part 非空,并要求 Part.Text 非空或 Part.InlineData 非空。文件名不能包含路径分隔符。service.go#L72-L123 定义了公共校验函数和保存请求的完整规则,其他请求在 #L130-L261 与 #L286-L318 复用相同的字段约束。

// 通过校验的最小文本 Part
req := &artifact.SaveRequest{
    AppName:   "weather",
    UserID:    "u-1",
    SessionID: "s-1",
    FileName:  "report.txt",
    Part:      genai.NewPartFromText("杭州今天晴转多云"),
}

这里有两个容易漏掉的边界:

  1. genai.NewPartFromBytes([]byte{}, “application/octet-stream”) 的 InlineData 非空,即使数据长度为零也能通过校验;“零字节”不等于“没有 Part”。
  2. user:profile.json 没有路径分隔符,能通过文件名校验;user:dir/profile.json 会被拒绝,不能借助用户命名空间绕过路径限制。

3. Part、版本元数据和适配器

保存与读取的 payload 是 genai.Part 指针;版本元数据则是另一种对象:

type ArtifactVersion struct {
    Version        int64
    CanonicalURI   string
    CustomMetadata map[string]any
    CreateTime     time.Time
    MimeType       string
}

定义见 service.go#L269-L284。CanonicalURI 是存储方案原生的身份字符串,不是下载授权;没有这类方案的实现可以留空。InMemory 只填版本和 MIME 类型,GCS 还填 URI、对象 metadata 和创建时间。

agent.Artifacts 的公开接口只有四个更贴近 Context 的方法:Save、List、Load、LoadVersion。agent/agent.go#L110-L117 刻意没有暴露 Delete、Versions 或 GetArtifactVersion。Runner 内部的 Artifacts 把这些调用转换成公共请求:

ctx.Artifacts().Save(ctx, "report.txt", part)
  → SaveRequest{
       AppName: weather, UserID: u-1, SessionID: s-1,
       FileName: report.txt, Part: part,
    }
  → artifact.Service.Save(...)

这层转换解释了一个 API 边界:应用可以在工具里保存和读取,但不能仅凭 agent.Artifacts 直接列出版本、删除单个版本或读取 ArtifactVersion 元数据;需要直接持有 artifact.Service 才能使用完整六操作契约。

两种后端的构造入口也在不同包中:测试或单进程示例可以直接调用 artifact.InMemoryService()(inmemory.go#L34-L45);GCS 则由 gcsartifact.NewService(ctx, bucketName, opts…) 创建,它先建立 cloud storage.Client,再返回实现 Service 的对象(gcsartifact/service.go#L59-L73)。Runner 配置哪一个,Context 侧的调用形状都不变。

4. SaveRequest.Version 的注释与当前实现

SaveRequest 的字段注释写着“如果设置,artifact 将以此版本保存;未设置则创建新版本”。但 v2.4.0 的两个内置 Save 实现都没有读取 req.Version:

因此,本文把“指定保存版本”视为接口字段上的意图,不是这两个实现当前兑现的行为。调用方若把 Version: 7 传给这两个后端,不能据此断言结果一定是 7;应以返回的 SaveResponse.Version 为准。这个差异也说明,读源码时不能只看 request struct 的注释,要继续追到实现函数。

三、主执行路径:一次文件保存如何变成版本和事件

1. Runner 先绑定 Session 身份

在运行节点时,Runner 只有在配置了 artifactService 后才创建内部适配器;AppName、UserID、SessionID 都来自已取得的 storedSession。run_node.go#L283-L313

if r.artifactService != nil {
    artifacts = &artifactinternal.Artifacts{
        Service:   r.artifactService,
        SessionID: storedSession.ID(),
        AppName:   storedSession.AppName(),
        UserID:    storedSession.UserID(),
    }
}

所以同一个工具代码在两个 Session 中调用 ctx.Artifacts().Save(ctx, “report.txt”, part) 时,文件名可以相同,实际键却不同。没有配置 Artifact Service 时,ctx.Artifacts() 会是 nil;工具必须先判断能力是否存在,不能假定每个 Runner 都提供文件存储。

2. 贯穿示例的调用栈与对象变化

工具 / callback
  → agent.Context.Artifacts().Save
    → internal/artifact.Artifacts.Save
      → artifact.SaveRequest{weather,u-1,s-1,report.txt,Part}
        → InMemoryService 或 gcsService
          → SaveResponse{Version: 1}

示例第一次保存 report.txt:

part1 := genai.NewPartFromText("杭州天气:晴转多云")
saved1, err := ctx.Artifacts().Save(ctx, "report.txt", part1)
// saved1.Version == 1

part2 := genai.NewPartFromText("杭州天气:晴转多云;建议带伞")
saved2, err := ctx.Artifacts().Save(ctx, "report.txt", part2)
// saved2.Version == 2

latest, _ := ctx.Artifacts().Load(ctx, "report.txt")
old, _ := ctx.Artifacts().LoadVersion(ctx, "report.txt", 1)

正常路径的快照如下:

时刻真实存储调用方拿到的值说明
T0没有 report.txtpart1 在内存中尚未写入 Service
T1report.txt@1 = part1SaveResponse{Version:1}第一次保存创建版本
T2report.txt@1 和 @2 = part2SaveResponse{Version:2}第二次保存创建新版本,不覆盖 @1
T3不变Load 返回 @2版本 0 解析为最新
T4不变LoadVersion(…,1) 返回 @1指定版本读取旧内容

Load 返回的是完整 Part;GetArtifactVersion 只返回版本、MIME、时间和后端元数据,不返回正文。两者是 payload 读取与对象描述读取的两个 API,不应互换。

3. Save 成功后,ArtifactDelta 才可能出现

工具 Context 的构造会使用 newTrackedArtifacts 包装 agent.Artifacts。在 NewToolContext 中,传入的 actions 会先补齐 StateDelta 和 ArtifactDelta,随后每次成功 Save 都会把返回版本写入动作 map:

resp, err := a.Artifacts.Save(ctx, name, data)
if err != nil {
    return resp, err
}
a.actions.ArtifactDelta[name] = resp.Version

完整实现见 common_context.go#L144-L155 与 #L464-L495。因此 report.txt 两次保存后的动作可能是:

真实 Artifact Service:report.txt@1、report.txt@2 已经存在
EventActions.ArtifactDelta:{"report.txt": 2}

这不是“两份内容被塞进 Event”,而是 Event 记录本次动作最后观察到的版本。若保存返回错误,wrapper 在写入 ArtifactDelta 前就返回,不会伪造版本摘要。

还有一个必须保留的限定:NewCallbackContext 直接使用 ic.Artifacts(),而 NewCallbackContextWithArtifactTracking 才显式包上 tracked wrapper(见 common_context.go#L70-L103)。所以不能笼统地说“所有 Context 保存都会自动产生 ArtifactDelta”;要看当前 callback 或 tool 使用的是哪一种 Context 构造路径。

tracked wrapper 自己也不负责并发版本仲裁:它直接把 resp.Version 赋给同名 key,源码注释还提示需要 RWLock 并检查多个工具保存同一文件时的更高版本(common_context.go#L482-L493)。后面 Flow 合并并行事件时取更高版本,是另一层的合并规则,不能反推 wrapper 内部已经线程安全。

调用点还可能经过 internal/context:它的 NewCallbackContext 明确调用 tracking 版本(internal/context/callback_context.go#L22-L35)。因此真正可复现的判断方法是沿调用方追到构造函数,而不是只根据“callback”这个名字推断是否会产生 delta。

4. 事件持久化不会替 Artifact Service 回滚

EventActions.ArtifactDelta 的定义只是 filename -> version:session/session.go#L248-L277。Session Service 保存 Event 时会复制这个 map(InMemory 示例见 session/inmemory.go#L229-L260),但不会重新保存文件,也不会根据 map 删除文件。

Save(Part) 成功 ───────► 外部 Artifact Service 已有版本
       │
       └─ tracked wrapper ─► EventActions.ArtifactDelta
                                  │
                                  ▼
                            Event / Session history

如果随后 Event append 失败,已经写入的 Artifact 不会由这条代码路径自动删除;反过来,Event 成功也不代表某个没有经过 tracked wrapper 的直接 Service 调用会被摘要。这里没有跨两个 Service 的事务。

5. 并行事件合并的是摘要,不是文件

Flow 合并并行 FunctionResponse 事件时,若两边都有 ArtifactDelta,mergeArtifactDeltas 对同名文件取更高版本:base_flow.go#L1502-L1548。

分支 A:{"report.txt": 3}
分支 B:{"report.txt": 4}
合并后:{"report.txt": 4}

这是事件摘要的合并规则,不是对 Artifact Service 的 compare-and-swap,也不会把版本 3 的内容复制到版本 4。真正的并发写入保护由后端实现决定,GCS 的规则见下一节。

四、关键细节与分支

4.1 user: 把 Session 作用域换成 User 作用域

普通文件 report.txt 的逻辑键包含 Session:

weather / u-1 / s-1 / report.txt / 2

当文件名以 user: 开头时,InMemory 在 Save、Load、Delete、Versions 和 GetArtifactVersion 中把 sessionID 改成固定的 user;List 则同时扫描当前 Session 和这个用户作用域。源码分别见 inmemory.go#L141-L152、#L165-L204 与 #L224-L325。

GCS 用对象名表达同一规则:

普通:app/user/session/file/version
用户:app/user/user/user:file/version

准确的构造函数在 gcsartifact/service.go#L76-L101。这里第二个 user 是作用域目录,后面的 user:file 仍是完整文件名;它不是把 user: 从名字中剥掉。

因此,在 s-1 保存 user:profile.json 后,用同一个 AppName 和 UserID、换成 s-2 仍能读取它;但换 UserID 或 AppName 就看不到。SessionID 仍然是请求必填字段,只是在存储键上不再决定用户作用域文件的位置。

4.2 InMemory:有序复合键让“最新”可被找到

InMemory 服务的核心结构是一个带读写锁的 omap.Map[string,genai.Part 指针]。键按 AppName、UserID、SessionID、FileName、Rev(Version) 编码:inmemory.go#L34-L78。版本用反向排序编码,因此 find 从范围起点拿到的第一项就是最新版本(#L98-L105)。

同一逻辑文件的键(概念化)
  (weather,u-1,s-1,report.txt,Rev(2))
  (weather,u-1,s-1,report.txt,Rev(1))

Save 在锁内查找最新版本并加一;Load(version>0) 精确取键,Load(version==0) 调用 find;Delete(version==0) 删除这个文件的版本范围。这里保存的是 genai.Part 指针,读取也返回存储的指针;实现没有在这层复制 payload。这使它很适合测试和演示,但不能把它的内存别名行为当作远端对象存储的通用保证。

4.3 InMemory 与 GCS 的 List 都会合并两种作用域

InMemory 的 List 先扫描当前 Session,再扫描 (AppName,UserID,“user”),用 map 去重后排序;GCS 的 List 则分别查询 Session 前缀和 User 前缀,也用 map 去重并排序(gcsartifact/service.go#L330-L391)。

对示例而言,当前 Session 里有 report.txt,用户作用域有 user:profile.json,List 返回:

[report.txt user:profile.json]

这个列表是文件名集合,不包含版本号;要显示版本必须另调 Versions。不要因为某个文件出现在 Session 的列表里,就推断它一定是 Session 独占文件。

4.4 版本 0 是一个按操作变化的哨兵值

调用Version=0Version>0
Loadresolve/find 最新版本精确读取该版本
Delete删除该文件的全部版本只删除指定版本
GetArtifactVersion取最新版本元数据取指定版本元数据
Versions没有版本参数返回全部版本

GCS 的 resolveVersion 在 gcsartifact/service.go#L443-L457 中显式使用 slices.Max;这也是为什么调用方不应依赖 Versions 返回顺序。InMemory 的扫描顺序来自有序键实现,GCS 的对象迭代顺序来自存储查询,接口本身没有承诺一个跨后端相同的顺序。

4.5 GCS Save 是“列出后抢占版本”,不是事务递增器

GCS 不能把“读取最大版本”和“创建下一个对象”合成一个列表操作。Save 的实际循环是:

列出已有版本
  ↓
next = max + 1(没有版本则为 1)
  ↓
以 DoesNotExist 前置条件写入 next
  ├─ 成功             → 返回 next
  ├─ HTTP 412 / gRPC FailedPrecondition
  │                    → 抖动退避,重新列版本再试
  └─ 其他错误         → 立即返回,不重试

实现和常量见 gcsartifact/service.go#L104-L119 与 #L121-L236:最多 16 次尝试,退避基准 10ms、上限 1s;耗尽时返回包装过的 ErrVersionConflict。调用方应使用 errors.Is(err, gcsartifact.ErrVersionConflict) 判断可重试的版本竞争,而不是把所有保存错误都重试。

这个方案只保护使用同一前置条件的写入者。若另一个客户端无条件覆盖同名对象,源码注释明确说版本方案可能丢失;它不是整个 bucket 的通用锁。

4.6 GCS 的正文回读与 InMemory 不完全相同

GCS Load 先取对象属性,再读全部字节,并用 genai.NewPartFromBytes(data, attrs.ContentType) 创建新的 Part(gcsartifact/service.go#L283-L327)。这意味着:

  • GCS 回读统一从字节和 MIME 还原,原始 Text/InlineData 的 Go 表示不必与保存时相同。
  • InMemory 直接返回保存的 genai.Part 指针,保留指针和原始字段形态。
  • 应用应依赖 Part 的语义内容,而不要依赖两个后端的内部指针、字段布局或别名行为。

写入时,GCS 对 InlineData 设置其 MIME 类型;对文本设置 text/plain(gcsartifact/service.go#L203-L225)。零字节 InlineData 仍会创建真实对象,测试专门覆盖了连续保存不能覆盖成同一个版本的情况。

4.7 GetArtifactVersion 的 URI 是身份,不是下载链接

GCS 版本元数据中的 CanonicalURI 固定为:

gs://<bucket>/<app>/<user>/<session-or-user-scope>/<filename>/<version>

实现选择 gs://,而不是对象的 MediaLink,因为 MediaLink 是认证的 JSON API 下载 URL;把它当作模型 file_uri 时,模型可能把 HTTP 页面当成文件而无法读取。源码注释和构造见 gcsartifact/service.go#L460-L504。

InMemory 的 GetArtifactVersion 只返回版本和 MIME 类型:文本默认为 text/plain,InlineData 使用其 MIME;CanonicalURI、CustomMetadata 和 CreateTime 保持零值(inmemory.go#L288-L327)。所以元数据展示页需要允许后端能力不同,不能把空 URI 当成“读取失败”。

4.8 错误、取消与已发生的副作用

请求校验错误在进入后端前返回;找不到对象的读取和元数据请求包装 fs.ErrNotExist。GCS 的列举、读写和删除都把 context.Context 传到底层客户端;退避等待也会在 Context 取消时返回 Context 错误(gcsartifact/service.go#L184-L200)。

还要留意一个契约边界:Service 接口注释说删除不存在的条目不应报错,但 GCS 的 Delete 直接把对象删除错误向上包装,没有在本层显式把 storage.ErrObjectNotExist 转成 nil(gcsartifact/service.go#L238-L280)。因此不能只看接口注释就假定真实 GCS 的 404 一定被吞掉;使用 GCS 时应把这个后端差异纳入错误处理和测试。

但取消和失败不是回滚:

Save 写入成功
  → 后续 Event append 失败 / Context 被取消
  → Artifact 版本仍可能留在后端

如果业务要求“文件和事件必须一起提交”,需要在 Artifact Service 与 Session Service 之上增加自己的幂等键、补偿删除或事务协调;当前接口没有提供跨服务事务。

五、重新梳理:运行时架构与边界

边界接收与产出负责的动作不负责的动作
工具 / callbackagent.Context → agent.Artifacts 调用选择文件名、构造 genai.Part、处理返回版本和错误不决定底层对象路径,不自动拥有事务
Runner / internal adapter当前 Session 身份 + 简化调用 → artifact.*Request注入 App/User/Session,暴露 Save/List/Load/LoadVersion不实现版本算法,不返回完整元数据 API
artifact.Service完整请求 → Part、版本、元数据或 error校验请求、保存/读取/删除/列举不创建 Session Event,不把结果交给模型
InMemory / GCSService 请求 → 具体对象存储版本分配、作用域映射、后端读写不保证跨后端相同的指针和元数据形态
Context tracked wrapper成功 SaveResponse → ArtifactDelta把文件名和版本写入当前 EventActions不保存 payload,不补偿失败,不代表所有 Context 都启用追踪
Session / EventEventActions → 历史记录持久化版本摘要并参与后续历史不根据 ArtifactDelta 重新上传或删除文件

把贯穿示例放回这张边界图:工具先调用 Context;Runner 预先把 Session 身份绑定到内部适配器;后端决定版本和对象路径;若当前 Context 是 tracked wrapper,成功版本才被写入 EventActions;Session 之后只保存这个摘要。模型下一轮若需要文件内容,仍要通过 Artifact 的 Load 路径取得 Part,不能从 ArtifactDelta 反解正文。

主线规则与旁路: 普通工具 Context 使用 NewToolContext,因此成功保存会有版本摘要;直接持有 artifact.Service 的代码可以绕过 Context,不自动产生 EventActions;普通 NewCallbackContext 也不等于 artifact-tracking callback,只有显式的 NewCallbackContextWithArtifactTracking 才包装 Save。把这三条路径混成一条,会错误推断事件总能反映所有文件写入。

六、这个模块解决了什么问题

1. 它解决的核心问题

Artifact 把“较大的、可能需要复用的二进制或文本 Part”从会话事件正文中分离出来,同时保留三个维度:

  1. 身份隔离:同名文件按 App、User、Session 区分;user: 提供跨 Session 的用户作用域。
  2. 版本可追溯:保存不会覆盖旧版本,调用方可以取最新、指定版本或列出版本。
  3. 后端可替换:测试可用 InMemory,生产可用 GCS;上层只依赖 Service 或 agent.Artifacts 契约。

2. 代价与适用边界

  • 每次保存产生版本,长期运行需要明确清理策略;Delete 是显式操作,不会因新版本自动回收旧版本。
  • GCS 每次保存至少要列版本再上传;并发高时会触发退避和 ErrVersionConflict,调用方需要处理重试和幂等。
  • Event 只记录版本摘要,Artifact 与 Session 没有跨服务事务;失败补偿必须由应用设计。
  • InMemory 的指针别名、GCS 的字节回读和元数据差异,意味着测试不能只断言某个后端的内部表示。
  • agent.Artifacts 的简化接口不覆盖删除、版本列表和元数据查询;需要管理功能时应在合适边界注入完整 artifact.Service,而不是从 EventActions 猜测。

七、重点结论与常见误解

误解源码事实
第二次 Save 会覆盖第一次两个内置实现都创建 max+1 版本;旧版本仍可读取
ArtifactDelta 就是文件内容它只是 filename -> version 的 EventActions 字段
每个 Context 的 Save 都自动写 delta只有 tracked wrapper 才记录;普通 callback Context 可能使用原始 Artifacts
user: 是普通文件名前缀而已它把存储键切换到同一 App/User 下的固定 user 作用域
Version=0 总是“版本 0”Load/Get metadata 取最新,Delete 删除全部
SaveRequest.Version=7 会写出 v7v2.4.0 的 InMemory/GCS Save 未读取这个字段,实际返回值才是事实
GCS CanonicalURI 可以当 HTTP 下载链接当前实现返回 gs:// 对象身份,不返回认证 MediaLink
Event 保存失败会撤销文件两个 Service 没有跨服务事务,已成功的 Save 可能保留
List 只列当前 Session 文件当前两个实现都会把当前 Session 和用户作用域文件合并返回

用贯穿示例检查自己

给定 weather/u-1/s-1:

  1. 保存 report.txt 两次后,Load(report.txt) 返回哪一版?
  2. 把文件改成 user:profile.json,换到 s-2 后还需要哪个身份保持不变?
  3. ArtifactDelta={“report.txt”:2} 能否恢复正文?如果不能,应该调用哪个 API?
  4. 两个 GCS writer 同时观察到最大版本为 3 时,为什么不会都成功写入版本 4?

答案分别是:最新版本 2;AppName 与 UserID;不能,需 Load 或 LoadVersion;DoesNotExist 前置条件让一个写入者收到 precondition failure 并重新列版本。

八、源码索引与进一步阅读

公共契约与测试

存储实现

运行时桥接

继续阅读 Context 可看 Context 派生、状态 delta 和取消如何影响工具;再读 Session & Event 可把 ArtifactDelta 放回 Event 持久化和历史重建的完整链路。