在 AI 应用里,"生成笔记""生成 PPT""生成测验""生成思维导图"这类操作通常都不是毫秒级请求。它们可能需要读取资料、拼接上下文、检索 RAG、调用 LLM,甚至还要做结构化解析和降级处理。如果继续用同步 HTTP 请求承载整条链路,用户会遇到几个典型问题:请求容易超时,页面等待体验差,后端并发压力不可控,任务失败后也缺少可追踪状态。
当前 YoudaoNoteLM 的生成模块已经把这类长耗时操作抽象成了异步任务。用户提交生成请求后,接口立即返回一个 pending 任务;后台 worker 从队列中消费任务,执行真正的生成逻辑,并把状态更新为 running、completed、failed 或 cancelled。前端不再等待生成接口直接返回内容,而是通过 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.go。Submit 方法做了四件事:
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 应用来说,这种设计非常合适。因为真正昂贵、不可控、耗时长的部分是模型生成,而异步任务模块要做的事情,是把这段不可控过程包进一个可观察、可取消、可查询、可失败恢复的工程壳里。