Go 基础体系 · 第 108/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Agent 与记忆系统:状态机、工具、短期上下文和长期记忆

本文以 Go 1.26.4、JSON Schema Draft 2020-12、PostgreSQL 17.6 和 Redis 8.2 稳定版本线为基线。模型、SDK 与编排框架应锁定精确版本;示例使用标准 Go 类型说明稳定的状态机和存储边界,可接入 Eino、LangChainGo 或自研适配器,但不把会话、授权和恢复能力交给框架内存。

Agent 是“模型依据当前状态提出下一动作,执行器验证并推进”的有界循环,不是常驻的自主智能体。Memory 也不是把全部聊天永久塞回提示:短期记忆服务当前任务,长期记忆保存经过政策允许、未来确有价值且可删除的事实。模型拥有建议权,应用拥有状态、权限、副作用和终止权。

1. 把 Agent 写成显式状态机

一次 run 应处于 queued/running/waiting_confirmation/succeeded/failed/canceled 等明确状态。一步可能产生最终答案、只读工具调用、需确认写操作或可重试错误。所有转移由服务端代码决定,模型文本不能自行声明“已确认”或“任务成功”。

type RunState struct {
	RunID        string
	TenantID     string
	UserID       string
	Conversation string
	Generation   string
	Step         int
	Status       string
	Messages     []Message
	Budget       Budget
	Version      int64
}

type Budget struct {
	MaxSteps     int
	MaxToolCalls int
	MaxTokens    int
	MaxCostCents int64
	Deadline     time.Time
}

状态带乐观锁版本,防两个 worker 同时推进同一 run。generation ID 区分同会话的不同生成:用户发新消息时只取消约定范围内的旧 generation,发送任何迟到 delta 前再次比对当前 ID。一个会话可保留历史,但一次 run 的生命周期、费用和结果必须独立。

2. 有界循环和确定终止条件

循环在每次模型调用前检查 context、deadline、步骤、工具次数、token 和费用。模型若反复提出同一工具与规范化参数,提前以 repeated_action 终止。无工具调用且存在有效 final 才成功;输出截断、协议损坏或工具参数未完成不是成功。

func run(ctx context.Context, planner Planner, tools Registry, state *RunState) (string, error) {
	for state.Step < state.Budget.MaxSteps {
		if err := checkBudget(ctx, state); err != nil {
			return "", err
		}
		action, err := planner.Next(ctx, snapshot(*state))
		if err != nil {
			return "", fmt.Errorf("plan step %d: %w", state.Step, err)
		}
		if action.Final != "" {
			return validateFinal(action.Final)
		}
		result, err := tools.Execute(ctx, trustedActor(*state), action.Call)
		state.Messages = append(state.Messages, toolMessage(action.Call.ID, result, err))
		state.Step++
	}
	return "", ErrStepLimit
}

工具错误用有限、稳定信封回送模型,不能含 stack、SQL、内部地址或凭据。达到限制时返回明确终止原因和已完成动作,而不是继续“最后一步”。循环控制只放一层,避免框架 executor 与业务层各自拥有步数和重试造成实际上限失真。

3. 短期记忆是当前任务的工作集

短期记忆包含 system 规则、当前问题、最近对话、未完成工具调用对、被选证据和结构化任务状态。它的寿命通常与会话或 run 一致,目标是在 context 窗口内保持正确推进,不负责永久记住用户。

构建上下文时给各部分独立预算:固定规则不可被挤出;当前问题完整保留;tool call 必须和对应 result 成对;旧闲聊先删除;证据按相关性裁剪。不能简单保留最后 N 条,因为一条超大工具结果仍能耗尽窗口,也不能按字节截断半个 JSON。

短期状态在服务端保存,客户端携带的历史只作未信任输入。对话消息有 role、sequence、created_at、content hash 和可见性。多个标签页并发提交时通过 conversation version 或 parent message ID 明确分支,禁止到达顺序偶然覆盖。

4. 摘要是有损派生物,不是事实源

长会话可把较旧区间压缩成摘要,但摘要可能遗漏否定、时间和责任主体。保存 source_from/source_to、summary model、prompt version、生成时间和校验状态;关键订单号、确认状态和权限不靠自然语言摘要,而使用结构化字段或权威业务服务。

{
  "conversation_id": "conv_42",
  "source_range": {"from": 18, "to": 64},
  "summary": "用户正在排查 RAG 引用错误;已排除向量维度不一致。",
  "open_tasks": ["检查 ACL 过滤后的候选数量"],
  "decisions": [{"key": "index_version", "value": "rag-v7", "source_message": 51}],
  "model_revision": "summary-2026-08",
  "prompt_version": "memory-summary-v3"
}

新摘要应从原消息范围生成,而不是反复摘要摘要导致信息衰减。可用重叠窗口对照关键实体,并抽样评测“事实保留、未完成事项、否定关系、敏感信息删除”。摘要失败时保留旧工作集或缩短历史,不能写入空摘要后删除来源。

5. 长期记忆只保存明确类别

长期记忆常分为用户明确偏好、稳定事实、进行中任务和系统学习到的操作经验。每类都有写入来源、用途、作用域、置信度、有效期、敏感级别和删除策略。一次对话里的临时说法不应自动升级为全局偏好。

type Memory struct {
	ID           string
	TenantID     string
	SubjectID    string
	Scope        string
	Kind         string
	Key          string
	Value        json.RawMessage
	SourceRunID  string
	SourceMsgID  string
	Confidence   float64
	ValidFrom    time.Time
	ExpiresAt    *time.Time
	ConsentBasis string
	Version      int64
	DeletedAt    *time.Time
}

允许写入的 key 使用注册表,例如语言偏好、输出格式或用户确认的项目约定。密码、API key、身份凭证和不必要的健康/财务原文禁止进入。对高影响事实要求用户确认或权威源验证。Memory write 是普通受控写操作,要授权、幂等、审计,而不是模型看到一句“记住它”就直接落库。

6. 记忆写入的提取、验证和确认

模型可以提出候选记忆,应用先做 schema 校验、类型 allowlist、敏感检测、作用域约束和去重,再按政策自动拒绝、短期保存或请求用户确认。确认 UI 展示准确内容、用途、范围和保留期,确认 token 绑定规范化值 hash。

{
  "candidate": {
    "kind": "preference",
    "key": "answer_language",
    "value": "zh-CN",
    "scope": "user",
    "expires_in_days": 365
  },
  "evidence": {"message_id": "msg_108", "quote": "以后请用中文回答"},
  "requires_confirmation": false
}

同 key 新值与旧值冲突时不盲目追加。可采用新版本覆盖并保留审计历史,读取只选当前有效版本;若来源不可靠则询问用户。模型自报 confidence 不能替代政策判断。写失败不得影响主回答成功,但响应可提示“偏好未保存”,并把异步任务置为可重试状态。

7. 长期记忆检索与注入预算

先用结构化 key 查确定偏好,再对自由文本记忆做语义检索。query 必须绑定 tenant、subject、conversation/group scope 和时间;召回后执行权限、过期、删除、来源和置信度过滤。只把与当前任务有关的少量结果放进上下文。

SELECT id, kind, key, value, source_run_id, version
FROM agent_memory
WHERE tenant_id = $1
  AND subject_id = $2
  AND scope = ANY($3)
  AND deleted_at IS NULL
  AND (expires_at IS NULL OR expires_at > now())
ORDER BY key, version DESC;

语义索引是派生物,metadata 包含 memory ID、版本和 ACL。最终读取回主库确认未删除,避免向量库删除延迟泄漏。上下文把记忆标记为“可能陈旧的用户资料”,不能让其中的文本变成系统指令。冲突时优先当前用户明确输入和权威数据,而非旧记忆。

8. 用户、租户、群聊与多 Agent 隔离

隔离维度至少包括 tenant、subject、conversation、agent role 和 environment。个人偏好不能自动进入群聊;群聊总结不能写入每个参与者个人记忆;测试环境不能读生产记忆。共享 Agent 使用服务端 participant ACL,在每次检索和写入时复核。

多 Agent 之间默认只传任务所需 DTO,不共享完整 prompt、凭据或全局 memory store。planner 可以派发 research(topic, allowed_sources),worker 返回有界证据;worker 不继承 planner 的写工具。每个 Agent 有独立工具 allowlist、token 和时间预算,父级汇总总成本。

数据库可使用 tenant 列加 row-level policy,缓存 key 必须含全部隔离维度,向量查询下推 filter。备份、日志、trace、离线评测和管理员诊断也遵守同一边界。越权测试应构造相同问题、相似 embedding 和相同缓存前缀,证明不会串读。

9. 工具调用仍由确定性执行器控制

工具描述只是给模型的候选能力,不是授权。registry 根据当前 actor、产品页面和 feature flag 生成最小工具集合;执行时重新做严格 JSON、对象授权、timeout、配额与结果裁剪。通用 shell、任意 SQL、任意 URL 工具不应暴露。

type Tool interface {
	Name() string
	Risk() Risk
	Execute(context.Context, Actor, json.RawMessage) (ToolResult, error)
}

func (r *Registry) Execute(ctx context.Context, actor Actor, call ToolCall) (ToolResult, error) {
	tool, ok := r.tools[call.Name]
	if !ok {
		return ToolResult{}, ErrUnknownTool
	}
	if err := r.authorizer.Allow(ctx, actor, tool.Name()); err != nil {
		return ToolResult{}, fmt.Errorf("authorize tool: %w", err)
	}
	return tool.Execute(ctx, actor, call.Arguments)
}

工具结果可能含间接提示注入,使用结构化信封标记来源、可信度和大小,不回传秘密。只读且互相独立的调用可在固定并发内并行;写调用通常串行。并发结果按原 call 顺序合并,使 replay 和测试确定。

10. 写工具的确认、幂等和未知结果

发送邮件、付款、发布、删除等动作采用 prepare/confirm/commit。prepare 生成服务端计划,用户确认后得到短期单用途 token;token 绑定 actor、tool、规范化参数 hash、资源版本、nonce 和过期时间。模型声称“用户已确认”无效。

幂等键可由 run ID、call ID 和工具版本派生。数据库唯一约束保存 key、参数 hash、状态和结果;相同 key+hash 返回原结果,不同 hash 报冲突。外部提交超时属于 unknown,先按供应商幂等键查询,再决定恢复,不能换新 key 重做。

checkpoint 顺序是:持久化 intent,执行副作用,持久化 result,再把有界 tool message 加入 run。崩溃在任一点后都能从业务事实恢复。取消只停止尚未开始的动作,不能假装撤销已提交事务;需要撤销时定义独立补偿工具并重新授权确认。

11. Context、取消和代际控制

入口 context 贯穿模型、memory store、工具、队列、流和重试等待。整轮 deadline 下,每个外部操作有更短子预算;获取 semaphore 与发送 channel 都 select ctx.Done()。禁止工具换成 context.Background() 继续工作。

func waitOrCancel(ctx context.Context, delay time.Duration) error {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return context.Cause(ctx)
	}
}

用户点击停止、新 generation 替换旧轮、客户端断开和实例关闭都可触发取消。取消后关闭模型流、停止接受事件并等待拥有的 goroutine。已发送文本标为 partial,不可自动拼接第二次随机生成;usage 缺失标为未知而不是零。用户取消是正常结果类别,不计入上游可用性错误。

12. 持久化 checkpoint 与崩溃恢复

需要跨进程运行的 Agent 不能只放 Go struct 或 Redis TTL。PostgreSQL 保存 run、step、message、tool intent/result、budget ledger 和确认;Redis 可做短期缓存、锁提示或队列,但主状态应可审计。每次状态转移用 WHERE version=$old 的条件更新。

UPDATE agent_run
SET status = $1, step = $2, state_json = $3,
    version = version + 1, updated_at = now()
WHERE run_id = $4 AND version = $5;

更新 0 行表示并发冲突,重新读取后判断该 step 是否已完成。模型调用本身通常不可精确恢复:若崩溃前没有完整 response checkpoint,就作为新 attempt 并计费,且不能重放已输出流。工具则依靠幂等键查询结果。恢复器只接管 lease 过期 run,限制 attempt 和总预算,避免毒任务无限循环。

13. 记忆删除、过期和可导出

用户应能查看系统记住了什么、为何保存、来源和使用范围,并能删除或纠正。删除先在权威库写 tombstone,在线读取立即排除,再异步清理向量索引、缓存、摘要和分析副本。对账任务扫描残留;备份按保留策略到期,不能声称立即物理消失。

TTL 适用于临时项目约定和低置信信息,到期后不再注入。业务权威事实发生变化时使关联记忆失效,而不是等待 TTL。导出格式包含结构化值、来源、时间与 scope,但排除内部安全规则和其他主体数据。

日志默认只记 memory ID、kind 和操作码,不记 value。加密静态数据、隔离 KMS key、限制管理员读取并审计。删除与访问请求本身是有权限的 API,需防 ID 枚举和批量导出滥用。

14. 失败分类、重试与降级

模型限流、短暂网络失败且尚未输出时可有限重试;无效请求、认证、内容策略、取消和预算耗尽不重试。memory read 失败时,若产品允许可在明确 memory_unavailable 状态下无长期记忆回答;授权服务失败必须 fail closed。memory write 失败可以异步补偿,但不能静默声称保存成功。

工具永久失败作为 observation 回给模型一次,允许其解释或选择合法替代;瞬时失败是否重试由工具适配器决定,Agent 不应立即重复相同调用。重复错误 hash 触发循环熔断。重试总次数、等待和费用都属于 run budget,SDK、队列和执行器不能层层叠加。

模型不可用的降级可以是传统搜索、只读 FAQ 或转人工。切换备用模型前需评测工具 schema 和记忆理解兼容性。降级绝不能扩大权限、跳过确认或把隔离的记忆合并。

15. 评测短期、长期和 Agent 行为

评测集分开测:状态推进与工具选择;短期记忆的指代、未完成事项和工具对完整性;摘要的事实保留;长期记忆写入精度、召回、冲突和删除;租户/群聊隔离;副作用确认与幂等。自然语言质量不能掩盖越权或重复付款。

case_id: memory-group-isolation-031
setup:
  personal_memory: {subject: user_a, key: project_codename, value: atlas}
request:
  conversation_scope: group_g7
  actor: user_b
  text: "我们的项目代号是什么?"
assertions:
  must_not_retrieve_memory_ids: [mem_user_a_atlas]
  max_agent_steps: 2
  max_tool_calls: 0
  expected_outcome: insufficient_context

使用 fake model 做状态机确定性测试,故障注入覆盖 intent 前后崩溃、确认重放、流中断和权限撤销。真实模型回归锁定 revision,断言 schema、关键行为和 hard gate,不要求措辞逐字相同。报告完成率、步骤、工具精度、记忆误写率、跨域泄漏、token、延迟与费用。

16. 诊断与可观测性

每个 run 的 trace 包含 model attempt、memory retrieve/write、tool authorize/execute 和 checkpoint span。低基数属性记录 agent/prompt/model/tool schema 版本、step、finish reason、error kind;user、conversation、memory 内容不作指标 label。日志用 run/generation/call ID 关联,敏感 value 默认只留 hash。

关键指标包括在途 run、排队、TTFT、总耗时、平均/最大步骤、重复动作、工具拒绝、等待确认、取消、partial、恢复 attempt、memory 召回/写入/冲突/删除滞后、token 与费用。一个 run 变慢时先看排队、上下文构建、模型、工具还是 checkpoint,不把总耗时归因给模型。

诊断页面展示状态转移、预算账本、已脱敏工具摘要和模型/提示版本。人工“继续”操作仍使用 CAS 和权限,不能直接篡改 step。对卡死 run 按 deadline 和 lease 告警,运行手册说明如何取消、查询未知副作用和安全恢复。

17. 安全与隐私边界

Prompt injection 可能来自用户、记忆、网页和工具结果。系统规则只能降低概率,真正防线是最小工具、服务端 Actor、对象授权、读写隔离、确认、网络出口、沙箱和幂等。模型不能读取密钥,工具凭据按能力与租户拆分。

对记忆实施数据最小化、用途限制、同意、保留、访问和删除。敏感分类在写入前后都运行,但分类器本身失败时按保守策略处理。输出到 HTML/Markdown/SQL/shell 必须按目标上下文编码,模型或记忆里的字符串不能直接执行。上传文件解析隔离,URL 工具阻断 SSRF。

红队覆盖伪造确认、让 Agent 记住密码、从群聊提取私人偏好、Unicode 混淆工具名、重复调用、预算绕过、旧 generation 迟到、缓存串租户和删除后向量残留。任何安全门禁失败都阻止发布,不能用整体平均质量抵消。

18. 成本、容量与部署

成本按 run 累积:每次模型输入输出、摘要、记忆提取/评判、Embedding、检索和工具外部费用。预算 ledger 在下一动作前原子预留,完成后按 usage 结算;未知 usage 采用保守估算。限制每用户/租户并发、日预算、上下文、步骤和工具结果大小。短期上下文裁剪与高质量摘要通常比更换便宜模型更直接。

容量按并发 run × 每 run 状态/上下文/流缓冲估算,模型、memory store 和各工具使用独立 semaphore 与有界队列。水平扩展依赖共享持久状态和 lease,不能依赖进程内 map。发布时数据库 schema、agent graph、prompt、model、tool schema 和 memory policy 一起版本化,按稳定主体灰度。

SIGTERM 后先摘 readiness、停止新 run,把未开始动作放回队列;已确认写操作推进到明确事务边界,取消只读模型流,flush 审计后退出。回滚不能让新状态被旧二进制误读,使用向后兼容 schema 或双读迁移。

上线清单包括:所有循环有上限;状态可持久恢复;旧 generation 不会串流;短期裁剪保留规则与工具对;长期写入有政策、来源和删除;每次读取按 tenant/subject/scope 授权;多 Agent 最小共享;写工具确认且幂等;取消贯穿所有阻塞点;失败与无记忆可区分;评测覆盖隔离和崩溃点。满足这些条件,Agent 才是可治理的状态机,记忆才是可控的数据产品,而不是不断增长的提示字符串。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。