本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段四 Agent 核心原理 · 第 6 课(30)
上一篇:Agent 的失败恢复、重试与任务状态管理

让模型调用只读查询工具,风险通常可控;让 Agent 发布文章、删除数据、退款或转账,则需要更严格的授权边界。

Human-in-the-loop(HITL)不是给模型加一句“请先询问用户”,而是在 Go 后端加入真实的暂停、审批、版本校验和恢复执行流程。

一、本课学习目标

学完本课,你应该能够:

  1. 区分高风险工具和普通工具。
  2. 设计 waiting_approval 任务状态。
  3. 将审批绑定到明确操作和参数。
  4. 拒绝确认后取消任务,而不是标记失败。
  5. 批准后重新检查状态与预算。
  6. 处理重复确认、状态漂移和并发提交。

二、Prompt 不是权限系统

下面只是行为指导:

发布文章前,请先征求用户同意。

模型仍可能误判、跳过或伪造“用户已同意”。程序不能依赖这段文字保护真实副作用。

可靠流程必须是:

模型提出高风险工具
   ↓
Go 识别风险等级
   ↓
保存具体待审批动作
   ↓
任务进入 waiting_approval
   ↓
有权限的用户确认或拒绝
   ↓
后端重新验证身份、版本、预算
   ↓
执行已批准的动作

批准的是“某一项明确操作”,不是“Agent 接下来可以做任何事”。

三、任务状态与审批对象

状态含义
running正在执行
waiting_approval正常暂停,等待人工确认
succeeded已成功完成
failed执行失败
cancelled被用户拒绝或主动取消

等待不是失败。它可能持续几分钟甚至更久,因此需要持久化和超时策略。

审批对象至少应保存:

type Action struct {
	Type             string
	ArticleID        int64
	Title            string
	ExpectedVersion int64
}

type ApprovalRequest struct {
	ID        string
	TaskID    string
	Action    Action
	CreatedAt time.Time
}

ExpectedVersion 用于确认执行对象在等待期间没有被别人修改。

四、完整 Go 示例

下面的示例先创建发布审批,用户批准后再由业务服务发布。

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type TaskStatus string

const (
	TaskRunning         TaskStatus = "running"
	TaskWaitingApproval TaskStatus = "waiting_approval"
	TaskSucceeded       TaskStatus = "succeeded"
	TaskFailed          TaskStatus = "failed"
	TaskCancelled       TaskStatus = "cancelled"
)

type Action struct {
	Type            string
	ArticleID       int64
	Title           string
	ExpectedVersion int64
}

type ApprovalRequest struct {
	ID        string
	TaskID    string
	Action    Action
	CreatedAt time.Time
}

type Task struct {
	ID              string
	Goal            string
	Status          TaskStatus
	PendingApproval *ApprovalRequest
	Result          string
	Error           string
}

type TaskStore struct {
	mu    sync.Mutex
	tasks map[string]*Task
}

func NewTaskStore() *TaskStore {
	return &TaskStore{tasks: make(map[string]*Task)}
}

func (s *TaskStore) Create(task *Task) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, exists := s.tasks[task.ID]; exists {
		return errors.New("任务 ID 已存在")
	}
	s.tasks[task.ID] = task
	return nil
}

func (s *TaskStore) Get(taskID string) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	task, exists := s.tasks[taskID]
	if !exists {
		return Task{}, errors.New("任务不存在")
	}
	return *task, nil
}

func (s *TaskStore) Update(
	taskID string,
	update func(*Task) error,
) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	task, exists := s.tasks[taskID]
	if !exists {
		return errors.New("任务不存在")
	}
	return update(task)
}

type Article struct {
	ID        int64
	Title     string
	Published bool
	Version   int64
}

type ArticleService struct {
	mu       sync.Mutex
	articles map[int64]Article
}

func NewArticleService() *ArticleService {
	return &ArticleService{articles: map[int64]Article{
		101: {
			ID:      101,
			Title:   "Go Context 入门",
			Version: 3,
		},
	}}
}

func (s *ArticleService) Get(
	ctx context.Context,
	id int64,
) (Article, error) {
	if err := ctx.Err(); err != nil {
		return Article{}, err
	}
	s.mu.Lock()
	defer s.mu.Unlock()

	article, exists := s.articles[id]
	if !exists {
		return Article{}, errors.New("文章不存在")
	}
	return article, nil
}

func (s *ArticleService) Publish(
	ctx context.Context,
	id int64,
	expectedVersion int64,
) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	s.mu.Lock()
	defer s.mu.Unlock()

	article, exists := s.articles[id]
	if !exists {
		return errors.New("文章不存在")
	}
	if article.Published {
		return errors.New("文章已经发布")
	}
	if article.Version != expectedVersion {
		return errors.New("文章内容已变化,请重新确认")
	}

	article.Published = true
	article.Version++
	s.articles[id] = article
	return nil
}

type Agent struct {
	store    *TaskStore
	articles *ArticleService
}

func (a *Agent) RequestPublish(
	ctx context.Context,
	taskID string,
	articleID int64,
) (Task, error) {
	article, err := a.articles.Get(ctx, articleID)
	if err != nil {
		return Task{}, err
	}
	if article.Published {
		return Task{}, errors.New("文章已经发布")
	}

	request := &ApprovalRequest{
		ID:     "approval-" + taskID,
		TaskID: taskID,
		Action: Action{
			Type:            "publish_article",
			ArticleID:       article.ID,
			Title:           article.Title,
			ExpectedVersion: article.Version,
		},
		CreatedAt: time.Now(),
	}

	task := &Task{
		ID:              taskID,
		Goal:            "发布文章",
		Status:          TaskWaitingApproval,
		PendingApproval: request,
	}
	if err := a.store.Create(task); err != nil {
		return Task{}, err
	}
	return *task, nil
}

func (a *Agent) ResolveApproval(
	ctx context.Context,
	taskID string,
	approvalID string,
	approverID string,
	approved bool,
) error {
	if approverID == "" {
		return errors.New("审批人身份不能为空")
	}

	task, err := a.store.Get(taskID)
	if err != nil {
		return err
	}
	if task.Status != TaskWaitingApproval ||
		task.PendingApproval == nil {
		return errors.New("任务当前不接受审批")
	}
	if task.PendingApproval.ID != approvalID {
		return errors.New("审批请求不匹配")
	}

	request := task.PendingApproval.Action

	if !approved {
		return a.store.Update(taskID, func(task *Task) error {
			if task.Status != TaskWaitingApproval {
				return errors.New("任务状态已经变化")
			}
			task.Status = TaskCancelled
			task.Result = "用户拒绝发布"
			task.PendingApproval = nil
			return nil
		})
	}

	err = a.store.Update(taskID, func(task *Task) error {
		if task.Status != TaskWaitingApproval ||
			task.PendingApproval == nil ||
			task.PendingApproval.ID != approvalID {
			return errors.New("审批状态已经变化")
		}
		task.Status = TaskRunning
		return nil
	})
	if err != nil {
		return err
	}

	err = a.articles.Publish(
		ctx,
		request.ArticleID,
		request.ExpectedVersion,
	)

	return a.store.Update(taskID, func(task *Task) error {
		task.PendingApproval = nil
		if err != nil {
			task.Status = TaskFailed
			task.Error = err.Error()
			return err
		}
		task.Status = TaskSucceeded
		task.Result = fmt.Sprintf("文章 %d 发布成功", request.ArticleID)
		return nil
	})
}

func main() {
	ctx := context.Background()
	store := NewTaskStore()
	articles := NewArticleService()
	agent := &Agent{store: store, articles: articles}

	task, err := agent.RequestPublish(ctx, "task-30", 101)
	if err != nil {
		panic(err)
	}

	fmt.Println("审批前状态:", task.Status)
	before, _ := articles.Get(ctx, 101)
	fmt.Println("审批前已发布:", before.Published)

	err = agent.ResolveApproval(
		ctx,
		task.ID,
		task.PendingApproval.ID,
		"user-42",
		true,
	)
	if err != nil {
		fmt.Println("审批执行失败:", err)
	}

	finalTask, _ := store.Get(task.ID)
	after, _ := articles.Get(ctx, 101)
	fmt.Println("最终状态:", finalTask.Status)
	fmt.Println("发布结果:", finalTask.Result)
	fmt.Println("最终已发布:", after.Published)
}

运行:

go mod init agent-lesson-30
go run .

示例只使用内存存储和模拟文章服务,不会发布真实博客内容。

五、关键安全设计

1. 审批绑定具体参数

“是否允许 Agent 继续”太模糊。应展示并保存:

工具:publish_article
文章 ID:101
标题:Go Context 入门
版本:3

批准后执行保存的参数,不能重新接受模型生成的另一组参数。

2. 批准后重新检查状态

等待期间,文章可能被其他人修改或发布。示例通过 ExpectedVersion 让业务层做条件更新。

真实数据库可以使用:

UPDATE articles
SET published = true, version = version + 1
WHERE id = ? AND version = ? AND published = false;

检查受影响行数,避免“先查询、再更新”之间出现竞态。

3. 真实验证审批人

示例只检查 approverID 非空,不能代表真实身份认证。生产系统必须验证:

  • 用户身份来自可信认证上下文。
  • 用户拥有批准该动作的权限。
  • 审批请求属于当前任务。
  • 审批节点符合组织授权规则。
  • 请求没有被重复提交。

4. 处理好“业务成功、状态保存失败”

示例先发布文章,再更新任务状态。若进程在两次操作之间退出,可能出现文章已发布但任务仍显示运行中。

生产环境需要事务、outbox、幂等记录或可查询的状态恢复机制,不能假设两个存储一定同时成功。

六、真实 HTTP 交互

适合的接口形态:

POST /api/agent/tasks
    创建任务,可能返回 waiting_approval

GET /api/agent/tasks/{task_id}
    查看当前状态与待审批操作

POST /api/agent/tasks/{task_id}/approval
    提交批准或拒绝

确认接口不应长时间占用原 HTTP 请求。前端拿到 waiting_approval 后,用户可在稍后完成审批。

七、本课总结

  1. waiting_approval 是正常暂停状态,不是失败。
  2. Prompt 不能代替程序级权限和审批。
  3. 审批必须绑定具体动作、资源和版本。
  4. 批准后仍需检查身份、权限、业务状态和预算。
  5. 并发更新要使用事务、条件更新或幂等机制。
  6. 持久化任务、审批和业务执行之间需要考虑跨存储一致性。

八、课后练习

练习 1:拒绝审批

用户拒绝发布后,任务与文章应处于什么状态?

参考答案:任务进入 cancelled,文章保持未发布。拒绝是正常业务分支。

练习 2:为什么不能只保存“已同意”

审批只记录一个布尔值有什么风险?

参考答案:无法确认用户同意的是哪个工具、哪篇文章、哪些参数和哪个版本。执行时可能被替换为另一项操作。

练习 3:批准后为什么还要检查

已经有人批准,为什么还要验证文章版本?

参考答案:等待期间资源可能被修改或已被别人发布。必须防止执行与用户审批时不同的内容,避免重复操作和状态漂移。


下一篇:Go AI Agent 实战 31|手写一个完整的 Go Agent

专栏:Go 开发者学 AI Agent:从大模型到工程落地

Logo

更多推荐