从同步生成到异步任务:YoudaoNoteLM 的生成队列模块设计

在 AI 应用里,"生成笔记""生成 PPT""生成测验""生成思维导图"这类操作通常都不是毫秒级请求。它们可能需要读取资料、拼接上下文、检索 RAG、调用 LLM,甚至还要做结构化解析和降级处理。如果继续用同步 HTTP 请求承载整条链路,用户会遇到几个典型问题:请求容易超时,页面等待体验差,后端并发压力不可控,任务失败后也缺少可追踪状态。

当前 YoudaoNoteLM 的生成模块已经把这类长耗时操作抽象成了异步任务。用户提交生成请求后,接口立即返回一个 pending 任务;后台 worker 从队列中消费任务,执行真正的生成逻辑,并把状态更新为 runningcompletedfailedcancelled。前端不再等待生成接口直接返回内容,而是通过 REST 轮询任务列表来感知任务进度。

核心代码分布在:

复制代码
internal/service/generation/generation_interface.go
internal/service/generation/task_service.go
internal/service/generation/task_queue.go
internal/service/generation/task_store.go
pkg/cache/generation_task.go
internal/api/v1/generation/controller.go
frontend/src/api/generation.ts
frontend/src/stores/useNotebookStore.ts

一、任务模型:先把"生成"变成可追踪对象

异步任务的第一步不是队列,而是任务模型。如果没有稳定的任务状态模型,队列只能做到"后台执行",做不到"用户可感知、可恢复、可排查"。

当前生成任务结构定义在 generation_interface.go

复制代码
type GenerationTaskStatus string

const (
	GenerationTaskStatusPending   GenerationTaskStatus = "pending"
	GenerationTaskStatusRunning   GenerationTaskStatus = "running"
	GenerationTaskStatusCompleted GenerationTaskStatus = "completed"
	GenerationTaskStatusFailed    GenerationTaskStatus = "failed"
	GenerationTaskStatusCancelled GenerationTaskStatus = "cancelled"
)

type GenerationTask struct {
	TaskID     string               `json:"task_id"`
	UserID     uint                 `json:"user_id"`
	NotebookID uint                 `json:"notebook_id,omitempty"`
	Type       GenerationType       `json:"type"`
	Status     GenerationTaskStatus `json:"status"`
	Result     *GenerationResponse  `json:"result,omitempty"`
	Error      string               `json:"error,omitempty"`
	CreatedAt  int64                `json:"created_at"`
	UpdatedAt  int64                `json:"updated_at"`
	Sequence   int64                `json:"sequence,omitempty"`
}

这个结构承担了几个职责:

复制代码
task_id     用来唯一标识一次生成请求
user_id     用来做权限隔离,防止用户查询或删除别人的任务
notebook_id 用来把任务挂到具体笔记本下
type        区分 note / mindmap / quiz / ppt
status      表示任务当前状态
result      保存生成成功后的内容
error       保存失败或取消原因
sequence    用于稳定排序

它的状态流转可以概括为:

复制代码
pending -> running -> completed
pending -> running -> failed
pending -> running -> cancelled
pending -> cancelled

在这个设计里,任务状态本身就是前端轮询的"事实来源"。前端不用关心 worker 是否还在跑,也不用维护复杂的 WebSocket 会话,只需要定期拉取 /generations/tasks

二、接口层:提交任务,而不是直接生成

生成接口在 internal/api/v1/generation/controller.go 中:

复制代码
func (ctrl *Controller) Generate(c *gin.Context) {
	userID := middleware.GetUserID(c)
	if userID == 0 {
		response.Unauthorized(c, "user is not authenticated")
		return
	}

	var req request.GenerationRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}

	task, err := ctrl.generationTaskService.Submit(c.Request.Context(), &service.GenerationRequest{
		UserID:       userID,
		NotebookID:   req.NotebookID,
		Markdown:     req.Markdown,
		Type:         service.GenerationType(req.Type),
		Prompt:       req.Prompt,
		Options:      req.Options,
		SourceIDs:    req.SourceIDs,
		UseWeb:       req.UseWeb,
		AllowDegrade: req.AllowDegrade,
	})
	if err != nil {
		response.BizError(c, err)
		return
	}

	response.Success(c, task)
}

这里最关键的变化是:POST /generations 不再等待 GenerationService.Generate 完整执行完,而是调用 generationTaskService.Submit 创建一个任务并入队。

路由设计也很清晰:

复制代码
group.POST("", ctrl.Generate)
group.GET("/tasks", ctrl.ListTasks)
group.GET("/tasks/:taskId", ctrl.GetTask)
group.DELETE("/tasks/:taskId", ctrl.DeleteTask)
group.POST("/export", ctrl.Export)

也就是说,生成模块被拆成了两类接口:

复制代码
POST   /generations              提交异步生成任务
GET    /generations/tasks         查询当前用户的任务列表
GET    /generations/tasks/:taskId 查询单个任务详情
DELETE /generations/tasks/:taskId 删除任务,活跃任务会先取消
POST   /generations/export        导出已生成内容

这种 API 形态比同步接口更适合 AI 生成场景。提交和执行解耦后,HTTP 请求只承担"登记任务"的职责,耗时逻辑交给后台 worker。

三、服务层:Submit 只负责登记和入队

异步任务的核心在 task_service.goSubmit 方法做了四件事:

复制代码
func (s *generationTaskService) Submit(ctx context.Context, req *GenerationRequest) (*GenerationTask, error) {
	if s.base == nil {
		return nil, bizerrors.New(bizerrors.CodeInternalServiceError, "generation service is not configured")
	}
	if err := validateGenerationRequest(req); err != nil {
		return nil, err
	}

	now := time.Now().Unix()
	task := &GenerationTask{
		TaskID:     uuid.New().String(),
		UserID:     req.UserID,
		NotebookID: req.NotebookID,
		Type:       req.Type,
		Status:     GenerationTaskStatusPending,
		CreatedAt:  now,
		UpdatedAt:  now,
		Sequence:   s.sequence.Add(1),
	}

	if err := s.store.Save(ctx, task); err != nil {
		return nil, bizerrors.NewWithErr(bizerrors.CodeInternalServiceError, "save generation task failed", err)
	}

	reqCopy := *req
	reqCopy.SourceIDs = append([]uint(nil), req.SourceIDs...)
	if req.Options != nil {
		reqCopy.Options = make(map[string]any, len(req.Options))
		for key, value := range req.Options {
			reqCopy.Options[key] = value
		}
	}

	if err := s.queue.Enqueue(ctx, queuedGenerationTask{taskID: task.TaskID, req: &reqCopy}); err != nil {
		task.Status = GenerationTaskStatusFailed
		task.Error = "generation task enqueue failed"
		task.UpdatedAt = time.Now().Unix()
		_ = s.store.Save(ctx, task)
		return nil, bizerrors.NewWithErr(bizerrors.CodeInternalServiceError, "generation task enqueue failed", err)
	}

	return task, nil
}

可以看到,Submit 并不执行生成逻辑。它只负责:

复制代码
1. 校验请求
2. 创建 pending 状态任务
3. 保存任务状态
4. 复制请求体并投递队列

这里有一个值得注意的细节:入队前会复制请求体。

复制代码
reqCopy := *req
reqCopy.SourceIDs = append([]uint(nil), req.SourceIDs...)
if req.Options != nil {
	reqCopy.Options = make(map[string]any, len(req.Options))
	for key, value := range req.Options {
		reqCopy.Options[key] = value
	}
}

这是为了避免任务进入后台后,原始请求对象被外部修改,导致 worker 看到的数据和提交时的数据不一致。异步任务里,"提交时的请求快照"非常重要。

四、队列抽象:同一套服务支持内存队列和 Redis 队列

队列接口定义很小:

复制代码
type GenerationTaskQueue interface {
	Enqueue(ctx context.Context, item queuedGenerationTask) error
	Dequeue(ctx context.Context) (queuedGenerationTask, error)
}

这个接口让任务服务不关心底层队列是 channel 还是 Redis。当前有两种实现:

复制代码
inMemoryGenerationTaskQueue  基于 Go channel,适合单机或开发环境
redisGenerationTaskQueue     基于 Redis Set,适合多实例部署

内存队列实现非常直接:

复制代码
type inMemoryGenerationTaskQueue struct {
	ch chan queuedGenerationTask
}

func NewInMemoryGenerationTaskQueue(size int) GenerationTaskQueue {
	if size <= 0 {
		size = generationTaskQueueSize
	}
	return &inMemoryGenerationTaskQueue{ch: make(chan queuedGenerationTask, size)}
}

func (q *inMemoryGenerationTaskQueue) Enqueue(ctx context.Context, item queuedGenerationTask) error {
	select {
	case q.ch <- item:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	default:
		return errors.New("generation task queue is full")
	}
}

func (q *inMemoryGenerationTaskQueue) Dequeue(ctx context.Context) (queuedGenerationTask, error) {
	select {
	case item := <-q.ch:
		return item, nil
	case <-ctx.Done():
		return queuedGenerationTask{}, ctx.Err()
	}
}

它的特点是:

复制代码
优点:实现简单,天然阻塞,性能高
缺点:进程重启后队列丢失,多个服务实例之间无法共享任务

Redis 队列则把任务 ID 放进 Redis Set:

复制代码
func (q *redisGenerationTaskQueue) Enqueue(ctx context.Context, item queuedGenerationTask) error {
	return q.cache.Enqueue(ctx, item.taskID, item.req)
}

func (q *redisGenerationTaskQueue) Dequeue(ctx context.Context) (queuedGenerationTask, error) {
	for {
		var req GenerationRequest
		taskID, err := q.cache.Dequeue(ctx, &req)
		if err == nil {
			return queuedGenerationTask{taskID: taskID, req: &req}, nil
		}
		if taskID != "" {
			return queuedGenerationTask{taskID: taskID}, err
		}
		if !errors.Is(err, redis.Nil) {
			return queuedGenerationTask{}, err
		}
		select {
		case <-ctx.Done():
			return queuedGenerationTask{}, ctx.Err()
		case <-time.After(redisTaskQueuePollInterval):
		}
	}
}

因为 Redis 的 SPOP 是非阻塞命令,所以当队列为空时,这里用 100ms sleep 模拟阻塞消费,避免 worker 空转打爆 Redis。

五、Redis 设计:任务状态、请求体、用户索引、队列分开存

Redis 相关代码在 pkg/cache/generation_task.go。当前用了四类 key:

复制代码
const (
	generationTaskPrefix        = "generation:task:"
	generationTaskRequestPrefix = "generation:task:request:"
	generationTaskUserPrefix    = "generation:task:user:"
	generationTaskQueueKey      = "generation:task:queue"
	generationTaskDefaultTTL    = 24 * time.Hour
)

对应关系如下:

复制代码
generation:task:<taskID>          保存任务状态和结果
generation:task:request:<taskID>  保存 worker 执行所需的请求体
generation:task:user:<userID>     Sorted Set,保存该用户的 taskID 列表
generation:task:queue             Set,保存待消费 taskID

入队逻辑:

复制代码
func (c *GenerationTaskCache) Enqueue(ctx context.Context, taskID string, req interface{}) error {
	reqKey := fmt.Sprintf("%s%s", generationTaskRequestPrefix, taskID)
	data, err := marshalCacheValue(req)
	if err != nil {
		return err
	}

	pipe := c.cache.client.TxPipeline()
	pipe.Set(ctx, reqKey, data, generationTaskDefaultTTL)
	pipe.SAdd(ctx, generationTaskQueueKey, taskID)
	_, err = pipe.Exec(ctx)
	return err
}

出队逻辑:

复制代码
func (c *GenerationTaskCache) Dequeue(ctx context.Context, dest interface{}) (string, error) {
	taskID, err := c.cache.client.SPop(ctx, generationTaskQueueKey).Result()
	if err != nil {
		return "", err
	}

	reqKey := fmt.Sprintf("%s%s", generationTaskRequestPrefix, taskID)
	if err := c.cache.Get(ctx, reqKey, dest); err != nil {
		return taskID, err
	}

	_ = c.cache.Delete(ctx, reqKey)
	return taskID, nil
}

这里使用 Redis Set 有几个取舍:

复制代码
SADD 入队:同一个 taskID 重复投递不会重复消费
SPOP 出队:原子弹出,多个 worker 不会拿到同一个 taskID
Set 队列:不保证严格 FIFO
TTL:任务和请求体默认保留 24 小时

这说明当前队列更关注"去重"和"简单可靠消费",而不是严格顺序。如果未来业务要求严格 FIFO,可以考虑 Redis List 的 LPUSH/BRPOP,或 Redis Stream 的 consumer group。

六、worker:真正执行任务的后台循环

任务服务创建时会启动 worker:

复制代码
func NewGenerationTaskServiceWithQueue(base GenerationService, store GenerationTaskStore, queue GenerationTaskQueue) GenerationTaskService {
	if store == nil {
		store = NewInMemoryGenerationTaskStore()
	}
	if queue == nil {
		queue = NewInMemoryGenerationTaskQueue(generationTaskQueueSize)
	}

	svc := &generationTaskService{
		base:  base,
		store: store,
		queue: queue,
	}

	go svc.worker()
	return svc
}

worker 是一个无限循环:

复制代码
func (s *generationTaskService) worker() {
	for {
		item, err := s.queue.Dequeue(context.Background())
		if err != nil {
			if item.taskID != "" {
				s.failDequeuedTask(item.taskID, err)
				continue
			}
			logger.Warn("dequeue generation task failed", zap.Error(err))
			time.Sleep(time.Second)
			continue
		}
		s.run(item.taskID, item.req)
	}
}

它的职责也很明确:

复制代码
1. 从队列取任务
2. 如果 taskID 已弹出但请求体读取失败,标记任务 failed
3. 正常取到任务后调用 run
4. 出队异常时记录日志并短暂 sleep

真正的状态流转在 run 里:

复制代码
func (s *generationTaskService) run(taskID string, req *GenerationRequest) {
	ctx := context.Background()

	task, err := s.store.Get(ctx, taskID)
	if err != nil {
		logger.Warn("read generation task before run failed", zap.String("task_id", taskID), zap.Error(err))
		return
	}
	if task.Status == GenerationTaskStatusCancelled {
		return
	}

	task.Status = GenerationTaskStatusRunning
	task.UpdatedAt = time.Now().Unix()
	_ = s.store.Save(ctx, task)

	taskCtx, cancel := context.WithTimeout(context.Background(), generationTaskMaxRunTime)
	s.cancelers.Store(taskID, cancel)

	resp, genErr := s.base.Generate(taskCtx, req)

	wasCancelled := taskCtx.Err() != nil
	cancel()
	s.cancelers.Delete(taskID)

	task, err = s.store.Get(ctx, taskID)
	if err != nil {
		return
	}
	if task.Status == GenerationTaskStatusCancelled {
		return
	}

	task.UpdatedAt = time.Now().Unix()
	switch {
	case errors.Is(genErr, context.DeadlineExceeded):
		task.Status = GenerationTaskStatusFailed
		task.Error = "生成超时,请重试"
	case errors.Is(genErr, context.Canceled) || wasCancelled:
		task.Status = GenerationTaskStatusCancelled
		task.Error = "任务已取消"
	case genErr != nil:
		task.Status = GenerationTaskStatusFailed
		task.Error = genErr.Error()
	default:
		task.Status = GenerationTaskStatusCompleted
		task.Result = resp
	}

	_ = s.store.Save(ctx, task)
}

这个流程里有两个重要设计。

第一,任务执行前会再次读取任务状态。如果任务已经被取消,worker 不会继续执行。

第二,每个任务都有最大执行时长:

复制代码
const generationTaskMaxRunTime = 10 * time.Minute

这可以防止底层 LLM 调用卡死,导致单个 worker 永久阻塞。超时后任务会进入 failed,错误信息是"生成超时,请重试"。

七、取消和删除:异步任务必须能收尾

异步任务如果只支持提交和查询,很快会遇到体验问题:用户误点了生成,任务卡住了,或者任务列表越来越多。因此当前模块提供了删除接口,并在服务层保留了取消能力。

取消逻辑:

复制代码
func (s *generationTaskService) CancelTask(ctx context.Context, userID uint, taskID string) error {
	task, err := s.store.Get(ctx, taskID)
	if err != nil {
		return bizerrors.NewWithErr(bizerrors.CodeResourceNotFound, "generation task not found", err)
	}
	if task.UserID != userID {
		return bizerrors.New(bizerrors.CodeForbidden, "generation task does not belong to current user")
	}

	switch task.Status {
	case GenerationTaskStatusPending, GenerationTaskStatusRunning:
		task.Status = GenerationTaskStatusCancelled
		task.Error = "任务已取消"
		task.UpdatedAt = time.Now().Unix()
		if err := s.store.Save(ctx, task); err != nil {
			return err
		}
		if cancel, ok := s.cancelers.Load(taskID); ok {
			cancel.(context.CancelFunc)()
		}
		return nil
	case GenerationTaskStatusCancelled:
		return nil
	default:
		return bizerrors.New(bizerrors.CodeConflict, "generation task is already finished")
	}
}

删除逻辑会先处理活跃任务:

复制代码
if task != nil && (task.Status == GenerationTaskStatusPending || task.Status == GenerationTaskStatusRunning) {
	if cancel, ok := s.cancelers.Load(taskID); ok {
		cancel.(context.CancelFunc)()
	}
}

if err := s.store.Delete(ctx, taskID); err != nil {
	return bizerrors.NewWithErr(bizerrors.CodeInternalServiceError, "delete generation task failed", err)
}

这里用 sync.Map 保存 taskID -> cancelFunc

复制代码
type generationTaskService struct {
	base      GenerationService
	store     GenerationTaskStore
	queue     GenerationTaskQueue
	sequence  atomic.Int64
	cancelers sync.Map
}

当任务真正进入执行阶段时,worker 会把 cancel function 放进去;任务完成后再删除。这样用户删除运行中任务时,可以尽量中断底层生成调用。

八、存储抽象:任务状态是唯一真相源

任务存储也被抽象成接口:

复制代码
type GenerationTaskStore interface {
	Save(ctx context.Context, task *GenerationTask) error
	Get(ctx context.Context, taskID string) (*GenerationTask, error)
	List(ctx context.Context, filter GenerationTaskListFilter) ([]*GenerationTask, error)
	Delete(ctx context.Context, taskID string) error
}

当前有内存版和 Redis 版。

内存版适合开发、测试或无 Redis 场景:

复制代码
type inMemoryGenerationTaskStore struct {
	mu    sync.Mutex
	tasks map[string]*GenerationTask
}

Redis 版则通过 GenerationTaskCache 保存任务状态:

复制代码
func (s *generationTaskCacheStore) Save(ctx context.Context, task *GenerationTask) error {
	return s.cache.Save(ctx, task.TaskID, task.UserID, task.Sequence, task)
}

func (s *generationTaskCacheStore) Get(ctx context.Context, taskID string) (*GenerationTask, error) {
	var task GenerationTask
	if err := s.cache.Get(ctx, taskID, &task); err != nil {
		return nil, err
	}
	return &task, nil
}

列表查询借助用户维度的 Sorted Set:

复制代码
func (c *GenerationTaskCache) Save(ctx context.Context, taskID string, userID uint, sequence int64, task interface{}) error {
	key := fmt.Sprintf("%s%s", generationTaskPrefix, taskID)
	score := float64(sequence)

	pipe := c.cache.client.TxPipeline()
	pipe.Set(ctx, key, data, generationTaskDefaultTTL)

	if userID != 0 {
		userKey := generationTaskUserKey(userID)
		pipe.ZAdd(ctx, userKey, redis.Z{Score: score, Member: taskID})
		pipe.Expire(ctx, userKey, generationTaskDefaultTTL)
	}

	_, err = pipe.Exec(ctx)
	return err
}

这样前端查询某个用户的任务列表时,不需要扫描所有任务 key,只需要从 generation:task:user:<userID> 取 taskID,再逐个读取任务详情。

九、应用启动:有 Redis 用 Redis,没有就退回内存

internal/app/app.go 中,任务模块初始化逻辑是:

复制代码
var generationTaskStore service.GenerationTaskStore
var generationTaskQueue service.GenerationTaskQueue

if a.redis != nil {
	generationTaskCache := cache.NewGenerationTaskCache(redisCache)
	generationTaskStore = service.NewGenerationTaskCacheStore(generationTaskCache)
	generationTaskQueue = service.NewGenerationTaskRedisQueue(generationTaskCache)
}

generationTaskSvc := service.NewGenerationTaskServiceWithQueue(
	generationSvc,
	generationTaskStore,
	generationTaskQueue,
)

如果 Redis 可用,就使用 Redis store 和 Redis queue;如果 Redis 不可用,传入的是 nil,任务服务内部会回退到内存实现:

复制代码
if store == nil {
	store = NewInMemoryGenerationTaskStore()
}
if queue == nil {
	queue = NewInMemoryGenerationTaskQueue(generationTaskQueueSize)
}

这让模块在开发环境和生产环境都能运行,只是能力不同:

复制代码
内存模式:简单、无外部依赖,但进程重启丢任务
Redis 模式:任务状态可跨请求保留,多个实例可以共享队列

需要注意的是,当前每个服务实例启动时都会启动一个 worker。单实例时任务是串行执行;多实例 Redis 部署时,全局并发量会变成实例数,因为每个实例都有自己的 worker,但 SPOP 能保证同一个 taskID 只会被一个实例消费。

十、前端轮询:不靠 WebSocket,也能有可感知进度

前端 API 定义在 frontend/src/api/generation.ts

复制代码
export async function generateFromMarkdown(req: GenerationRequest): Promise<GenerationTask> {
  const res = await client.post<{ code: number; data: GenerationTask; message?: string }>(
    '/generations',
    req,
    { timeout: 30000 }
  );
  if (res.data.code !== 0) {
    throw new Error(res.data.message || '生成失败');
  }
  return res.data.data;
}

export async function listGenerationTasks(params?: { notebook_id?: number; limit?: number }): Promise<GenerationTask[]> {
  const res = await client.get<{ code: number; data: GenerationTask[]; message?: string }>(
    '/generations/tasks',
    { params, timeout: 30000 }
  );
  if (res.data.code !== 0) {
    throw new Error(res.data.message || '获取生成队列失败');
  }
  return res.data.data || [];
}

提交任务后,前端会把任务加入本地 pending 集合,然后启动轮询:

复制代码
const generationTaskPollInterval = 2000;

connectGenerationTasks: (notebookId) => {
  if (!notebookId) return;

  stopGenerationTaskPolling();
  generationTaskPollNotebookId = notebookId;

  const tick = async () => {
    let hasActiveTask = false;

    const tasks = await generationApi.listGenerationTasks({
      notebook_id: Number(notebookId),
      limit: 100,
    });

    for (const task of tasks) {
      if (task.status === 'pending' || task.status === 'running') {
        hasActiveTask = true;
      }
      handleTerminalTask(task);
    }

    if (!hasActiveTask) {
      generationTaskPollTimer = null;
      return;
    }

    generationTaskPollTimer = setTimeout(tick, generationTaskPollInterval);
  };

  void tick();
}

这里的轮询策略也比较克制:

复制代码
轮询间隔 2 秒
只有存在 pending/running 任务时持续轮询
任务全部进入终态后自动停止
网络错误时如果本地仍有 pending 任务,会继续轮询以便恢复

任务完成后,前端会把生成结果转成普通 note:

复制代码
export function createNoteFromGenerationTask(task: GenerationTask): Note | null {
  if (!task.result?.content || !task.notebook_id) return null;

  const type = task.type as NoteType;
  const firstLine = task.result.content.split('\n')[0] || '';
  const autoTitle = firstLine.replace(/^#+\s*/, '').trim() || `新${generationTypeLabels[type]}`;

  return {
    id: generatedTaskNoteId(task.task_id),
    title: autoTitle.slice(0, 40),
    type,
    content: task.result.content,
    isSource: false,
    notebookId: String(task.notebook_id),
    createdAt: timestampToISOString(task.created_at),
    updatedAt: timestampToISOString(task.updated_at || task.created_at),
  };
}

也就是说,异步任务只是生成过程的承载体;一旦完成,它会被物化成用户真正看到的笔记、PPT、测验或思维导图内容。

十一、这个设计解决了什么问题

这个异步任务模块的价值可以总结成五点。

第一,HTTP 请求不再被长耗时生成阻塞。提交接口只负责登记任务,响应时间稳定。

第二,任务状态可观测。用户可以看到任务排队中、执行中、失败、取消、完成,而不是面对一个转圈的按钮。

第三,后端压力可控。单实例默认一个 worker 串行执行,避免多个生成请求同时压垮 LLM 服务。

第四,失败可恢复。生成失败后错误会写入任务状态,前端可以展示错误,用户也能删除任务后重试。

第五,部署模式灵活。无 Redis 时可以用内存队列跑起来,有 Redis 时可以使用分布式队列和缓存存储。

十二、当前实现的取舍和后续优化方向

当前实现是一个务实版本,不复杂,但已经覆盖了异步任务最核心的能力。不过它也有明确取舍。

Redis Set 不保证 FIFO:

复制代码
优点:去重简单,SPOP 原子消费
缺点:任务执行顺序不严格

如果未来非常在意先进先出,可以改成:

复制代码
Redis List: LPUSH + BRPOP
Redis Stream: XADD + XREADGROUP

多实例下并发量等于实例数。当前每个应用实例都会启动一个 worker。如果部署 3 个实例,就可能同时跑 3 个生成任务。这对吞吐有好处,但如果 LLM 配额很紧,需要增加全局限流或 worker 数配置。

任务默认 TTL 是 24 小时。这适合临时生成结果,但如果用户希望长期追溯历史任务,任务结果应该落 MySQL,Redis 只做队列和热缓存。

删除 pending 任务时,Redis Set 里可能仍残留 taskID。当前 worker 弹出后会读取任务,读不到会记录失败或跳过。更彻底的做法是在删除时同时 SREM generation:task:queue taskID

队列没有重试机制。现在失败会直接标记 failed。对于临时网络错误、LLM 限流错误,可以引入 retry count、next_retry_at、dead letter queue 等字段。

十三、一个最小异步任务流程示例

从用户点击"生成笔记"开始,完整链路大致是:

复制代码
1. 前端 POST /generations
2. 后端创建 pending 任务
3. 后端保存 generation:task:<taskID>
4. 后端保存 generation:task:request:<taskID>
5. 后端 SADD generation:task:queue taskID
6. 接口返回 task_id
7. 前端开始 GET /generations/tasks 轮询
8. worker SPOP 取出 taskID
9. worker 读取请求体
10. worker 标记任务 running
11. worker 调用 GenerationService.Generate
12. 成功则保存 completed + result
13. 失败则保存 failed + error
14. 前端轮询到 completed
15. 前端把 result.content 转成 Note 展示

这个流程的关键不是"用了 Redis",而是系统把生成过程拆成了三个稳定边界:

复制代码
HTTP 层:提交任务和查询状态
任务层:管理状态、队列、取消、删除
生成层:执行真正的 AI 内容生成

边界清楚之后,模块就具备了扩展空间。以后无论是增加 worker 数、换队列中间件、做任务重试、做后台管理页,还是把结果持久化到数据库,都不需要推翻现有结构。

结语

YoudaoNoteLM 当前的异步任务模块是一个典型的"小而完整"的工程实现:接口不复杂,队列抽象很薄,状态模型清晰,前端通过轮询闭环体验。它没有一上来引入庞大的任务调度系统,而是用 Go channel 和 Redis Set 覆盖了当前生成场景最需要的能力。

对于 AI 应用来说,这种设计非常合适。因为真正昂贵、不可控、耗时长的部分是模型生成,而异步任务模块要做的事情,是把这段不可控过程包进一个可观察、可取消、可查询、可失败恢复的工程壳里。

相关推荐
程序员萤火1 小时前
LLM Agent 底层揭秘:大模型如何通过 JSON-RPC 2.0 协议跨进程调工具?
agent
大模型momo2 小时前
AI 编程工程化:从 Prompt 到 Harness
人工智能·prompt·编程·agent
小满zs4 小时前
Go语言第四章(类型转换)
后端·go
周末程序猿14 小时前
图解 120 个大语言模型(LLM)核心概念(91-120)
llm·agent
小白跃升坊14 小时前
倒反天罡!DeepSeek V4-Flash 正式版悄然上线:130亿激活参数,把自家1.6万亿旗舰「以下克上」
ai·大模型·agent·deepseek·v4
张彦峰ZYF18 小时前
全球开源大模型生态-从开放权重到开放智能系统:发展、进展、主力模型成就与方向分析
人工智能·开源·llm·agent
用户77833661321121 小时前
从 0 搭一个 SERP API + LLM Agent 端到端实战(2026年7月)
llm·api·agent