Go AI Agent 实战 30|Human-in-the-loop:关键操作前的人工确认
本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段四 Agent 核心原理 · 第 6 课(30)
上一篇:Agent 的失败恢复、重试与任务状态管理
让模型调用只读查询工具,风险通常可控;让 Agent 发布文章、删除数据、退款或转账,则需要更严格的授权边界。
Human-in-the-loop(HITL)不是给模型加一句“请先询问用户”,而是在 Go 后端加入真实的暂停、审批、版本校验和恢复执行流程。
一、本课学习目标
学完本课,你应该能够:
- 区分高风险工具和普通工具。
- 设计
waiting_approval任务状态。 - 将审批绑定到明确操作和参数。
- 拒绝确认后取消任务,而不是标记失败。
- 批准后重新检查状态与预算。
- 处理重复确认、状态漂移和并发提交。
二、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 后,用户可在稍后完成审批。
七、本课总结
waiting_approval是正常暂停状态,不是失败。- Prompt 不能代替程序级权限和审批。
- 审批必须绑定具体动作、资源和版本。
- 批准后仍需检查身份、权限、业务状态和预算。
- 并发更新要使用事务、条件更新或幂等机制。
- 持久化任务、审批和业务执行之间需要考虑跨存储一致性。
八、课后练习
练习 1:拒绝审批
用户拒绝发布后,任务与文章应处于什么状态?
参考答案:任务进入
cancelled,文章保持未发布。拒绝是正常业务分支。
练习 2:为什么不能只保存“已同意”
审批只记录一个布尔值有什么风险?
参考答案:无法确认用户同意的是哪个工具、哪篇文章、哪些参数和哪个版本。执行时可能被替换为另一项操作。
练习 3:批准后为什么还要检查
已经有人批准,为什么还要验证文章版本?
参考答案:等待期间资源可能被修改或已被别人发布。必须防止执行与用户审批时不同的内容,避免重复操作和状态漂移。
下一篇:Go AI Agent 实战 31|手写一个完整的 Go Agent
专栏:Go 开发者学 AI Agent:从大模型到工程落地
更多推荐


所有评论(0)