PRD.md 17 KB

find_agent 当前执行逻辑 PRD

文档版本:v2.1 代码基线:2026-08-03 文档性质:As-Is 现状说明

1. 文档范围

本文只描述 find_agent 当前已经存在的内部执行逻辑,包括:

  • Agent 实例配置;
  • 单次运行的输入构造;
  • 模型循环与工具调用;
  • 搜索、候选、证据和评估状态;
  • 持久化状态变化;
  • 正常结束、超时和失败判定;
  • Prompt 约束与代码硬约束的实际边界。

2. Agent 实例状态

项目 当前值
Agent 名称 find_agent
默认模型 google/gemini-3-flash-preview
模型覆盖 创建或运行时显式传入 model 可覆盖默认模型
温度 0.2
最大模型迭代 60
单次运行总超时 默认 600
超时配置 FIND_AGENT_TIMEOUT_SECONDS,最小 60 秒,最大 7200
执行方式 异步 ReAct 循环;同步入口负责创建并关闭事件循环
视频理解 不可用;qwen_video_analyze 未注册到 Agent

Agent 初始化时注册一个内置 load_skill 工具和 12 个 find_agent 专属工具。当前 find_agent 没有预加载 Skill,正常执行依赖系统 Prompt 和已注册工具。

3. 单次运行输入状态

3.1 上下文对象

单次调度运行对应一个 FindDemandContext

字段 含义
biz_dt 业务日期,格式为 YYYYMMDD
demand_grade_id 本次需求记录 ID
demand_name 传给 Agent 的 demand_word
grade 当前需求等级
videos 该需求下的参考视频集合
videos[].video_id 参考视频 ID
videos[].title 参考视频标题;缺失时使用 (无标题\|video_id)
videos[].points 参考视频对应的有效拓展点位

点位只接受 inspirationpurposekey 三种类型。空点位被丢弃,同一视频内按 (point_type, expanded_text) 去重。没有有效拓展点位的参考视频不会进入上下文;没有 有效参考视频的需求不会生成运行上下文。

上下文加载后的实际排序键为:

score 降序 → grade(S 在 A 前)→ demand_name → demand_grade_id

因此当前实现先比较 score,只有同分时才比较 S/A 等级。

3.2 运行预创建

进入模型循环前,系统先按 (biz_dt, demand_grade_id) 预创建或复用 video_discovery_run

  1. 新任务生成随机 run_id,初始状态为 running
  2. 已有记录且未启用 force 时:
    • 状态为 finished,跳过;
    • attempt_count >= 1,跳过;一次技术失败也不会自动重试;
  3. 已有记录但不满足跳过条件时,复用原 run_id,并将运行状态重置为 running
  4. 启用 force 时可人工重跑,复用已有 run_id 并重置运行输入,不新建第二条同需求运行。

复用或强制运行不会清空原 run_id 下的搜索页和候选;attempt_count 记录执行次数。

3.3 模型用户消息

模型收到的用户消息包含:

  • 预创建的 run_id
  • demand_grade_id
  • demand_word
  • 全部 reference_videos 及各自点位;
  • 直接复用给定 run_id 开始搜索、更新候选和管理状态的指令。

relevant_points 不传给模型,也不要求模型生成。调度程序在预创建运行时已经完成点位 展平,并保存展平结果、完整参考视频快照以及兼容旧表结构的主参考视频字段。

4. 内部执行状态机

4.1 逻辑阶段

阶段 进入条件 内部动作 退出条件
CONTEXT_READY 已构造有效上下文 生成输入快照 准备运行记录
RUNNING 运行记录已预创建或重置 启动日志与 ReAct 循环 模型请求工具或直接回答
SEARCHING 模型调用召回工具 关键词、翻页、标签或作者扩展 搜索结果返回
PERSISTING_SEARCH 搜索页已返回 自动新增搜索记录,并为本页每条结果新增候选记录 候选进入待评估态
EVIDENCE_GATHERING 模型选择高潜候选 获取详情、视频画像、作者画像并标准化 模型认为证据足够
EVALUATING 候选具备可用证据 生成 R/E/S/V、理由和最终分池 评估写入数据库
FINAL_QUERY 候选与运行状态已保存 重新读取数据库最终状态 模型生成最终文本
LOOP_DONE 模型返回不含工具调用的消息 结束 ReAct 循环 进入运行结果判定
FAILED 外层异常、超时或结果侧失败 将运行标记为 failed 本次执行结束

逻辑阶段没有单独持久化字段。数据库只持久化运行、搜索页和候选三个层级的状态。

4.2 持久化状态

运行状态:

  • running
  • finished
  • failed

搜索页状态:

  • success
  • failed

候选状态:

  • pending_evaluation
  • primary
  • rejected

数据库兼容读取旧值 unreviewed,读取时统一映射为 pending_evaluation

5. ReAct 循环逻辑

每轮执行顺序为:

  1. 将系统 Prompt、历史消息和当前工具定义发送给模型;
  2. 模型返回普通消息或一个及以上工具调用;
  3. 无工具调用时,当前普通消息立即成为 AgentResult.content,循环结束;
  4. 有工具调用时,按模型返回顺序逐个执行;
  5. 每个工具结果写入运行日志并追加为 tool 消息;
  6. 下一轮模型基于完整消息历史继续决策。

同一轮中的多个工具调用不是并行执行,而是顺序执行。工具返回 JSON 中即使包含 error,循环框架也只把它标记为工具错误并交还模型,不自动重试、不自动失败,也不 自动切换替代工具。

5.1 非法工具参数恢复

模型供应方返回 MALFORMED_FUNCTION_CALL 时:

  1. LLM 客户端最多进行 3 次内部重试,即一次原请求加 3 次重试;
  2. 重试时温度降为 0,关闭并行工具调用,并附加“只调用一个必要工具”的修正指令;
  3. 连续失败后向 AgentLoop 抛出 MalformedFunctionCallError
  4. AgentLoop 追加一条参数修正消息,然后消耗下一次 Agent 迭代继续执行。

5.2 最大迭代结束

完成 60 次迭代后,框架追加“立即给出当前最佳答案”的消息,再发起一次不携带工具定义的 模型请求。该请求只能生成文本,不能继续完成搜索、保存或最终状态查询。

框架不会在模型输出最终文本前检查运行状态或数据库一致性。

6. 搜索与候选召回逻辑

6.1 搜索计划

搜索词、搜索顺序和是否扩展由模型决定。系统 Prompt 当前要求:

  • 从需求、参考标题和相关点形成 2~3 个语义不同的根搜索;
  • 根搜索来源使用 demandseedpointmixed
  • 标签、作者和翻页扩展使用 tagauthorpagination
  • 尽量形成 5 条 decision_bucket=primary 的通过视频;
  • 优先验证语义不同的有效根搜索,并按信息价值决定是否翻页或扩展标签;
  • 剩余前沿不再可能改变候选判断、排序或置信度时停止。

以上搜索策略由 Prompt 驱动,AgentLoop 不包含固定搜索计划器。

6.2 搜索来源

来源 当前行为
douyin_search 内部关键词搜索;返回标题、作者、点赞、评论、分享和分页游标
douyin_search_tikhub TikHub 独立搜索;额外返回话题、收藏、播放、时长及完整分页状态
douyin_user_videos 按作者 sec_uid 获取最热或最新作品

内部关键词搜索和作者作品接口在进程内分别执行至少 10.1 秒的调用间隔控制;TikHub 搜索执行至少 1 秒的调用间隔控制。

TikHub 翻页需要原样复用上一页的 next_cursorsearch_idbacktrace。三个来源的 游标彼此独立。

6.3 搜索页持久化

搜索工具在外部接口返回后自动完成持久化。当前处理规则为:

  1. 每次搜索、每一页都新增一条 video_discovery_search,相同参数重复执行也不会覆盖;
  2. page_no > 1 时强制将来源类型改为 pagination
  3. 根来源自动清空 parent_search_id
  4. 搜索失败也新增搜索记录,保存 failed 和原始错误,不新增候选;
  5. 搜索成功后,本页每条有效结果都新增一条 video_discovery_candidate
  6. 候选通过 search_id 直接关联本次搜索;
  7. 同一 aweme_id 被不同搜索命中时允许重复插入,每条记录拥有独立 candidate_id
  8. 新候选状态统一为 pending_evaluation
  9. 搜索工具返回视频基础信息,并把数据库生成的 search_id/candidate_id 拼入结果。

7. 证据获取逻辑

7.1 详情核验

douyin_detail 单次最多处理 8 个视频 ID,用于更新:

  • 标题和描述;
  • 作者信息;
  • 话题标签;
  • 页面链接;
  • 播放、点赞、评论、收藏和分享数据;
  • 发布时间和视频时长。

详情工具的新流程使用 run_id + candidate_ids:完整上游响应先写入 video_discovery_evidence,再由程序抽取详情字段、更新同一运行内的候选并重算门禁。 旧 content_ids 调用保留兼容,但不会自动关联候选。

7.2 年龄画像

年龄证据包含两个独立来源:

  • 视频点赞用户画像:内容侧直接证据;
  • 作者粉丝画像:账号侧先验。

batch_fetch_portraits 单次最多处理 8 个候选,按输入顺序逐条请求内容画像;设置 fetch_account_portrait=true 时同时请求作者画像。每条结果自动生成标准化年龄结果。 任一上游画像请求可能单独失败;保存时未获取侧保持 missing,只要视频侧或作者侧任一侧 达到对应 50+ 门槛,画像门禁即通过。

批量画像的新流程使用 run_id + candidate_ids,内容侧和账号侧原始响应分别追加保存; 标准化和候选更新由工具完成,不再要求模型搬运画像 JSON。

单条画像工具只返回原始画像,需要额外调用 normalize_age_portraits

7.3 年龄标准化

年龄标准化把画像桶归为:

  • older:明确覆盖 50 岁及以上;
  • mature:覆盖 40 岁以上但不能作为直接老年桶;
  • younger
  • unknown

每侧画像根据老年占比、老年 TGI 和成熟人群占比确定:

  • strong
  • moderate
  • weak
  • missing

双侧结果再生成:

  • aligned:两侧强弱方向一致;
  • conflict:两侧强弱方向冲突;
  • content_only
  • account_only
  • missing

标准化工具还返回 elder_score_cap:双侧、仅内容侧为 1.0,仅账号侧为 0.65,两侧 均缺失为 0.35。该上限只作为返回给模型的决策信息,保存工具不会执行分数上限校验。

8. 候选判断逻辑

8.1 三项命题

模型分别判断:

  • R:候选是否满足需求真实意图;
  • E:视频点赞用户和作者粉丝画像是否支持较高年龄受众倾向;
  • S:候选是否同时具备传播行为信号和可解释分享动机。

最终目标为 R ∩ E ∩ S。相关性是准入闸门,分享规模和年龄倾向不能弥补低相关。

8.2 证据使用顺序

当前 Prompt 要求:

  1. 先用搜索标题、描述、话题和互动数据进行低成本预筛;
  2. 默认最多选 8 条高潜候选进入详情和双画像阶段;
  3. 视频画像优先于作者画像;
  4. 双侧一致时增强置信度,冲突时优先视频画像并降低置信度;
  5. 数据缺失视为未知,不作为负证据;
  6. 高度重复候选只保留证据更强的一条;
  7. 价值相近时优先覆盖不同需求点或分享动机。

8.3 评分与分池

Prompt 定义联合价值关系:

V = R^0.40 × E^0.35 × S^0.25

数据库中的 R/E/S/V 都使用 0~1 小数。当前保存实现不会计算 V、不会校验分数 范围,也不会根据分数调整分池。R/E/S 最多保留 6 位小数,V 最多保留 2 位小数; 无法转换为数字的值按缺失处理。

最终分池只接受:

  • primary
  • rejected

分池完全由模型决定。更新层仅校验候选 ID 和枚举合法性,不校验:

  • R/E/S/V 是否齐全或在 0~1 范围;
  • V 是否符合公式;
  • primary 是否满足证据条件;
  • 最终理由是否齐全。

候选更新必须使用搜索工具返回的 candidate_id。不存在或不属于当前 run_id 时整批 失败,不允许补建候选,也不允许用 aweme_id 更新同视频的其他搜索记录。请求为 primary 但未通过 P0 的候选会逐条自动保存为 rejected,同批其他合格候选不会回滚。 候选更新工具只修改 video_discovery_candidate;运行状态由独立工具修改。

9. 结束与输出流程

Prompt 规定的正常结束流程为:

停止搜索 → 获取必要证据 → 按 candidate_id 更新已作出的候选判断 → 将运行设为 finished → 输出

update_video_discovery_run_status(status="finished") 会把剩余过程候选归档为 rejected, 按 aweme_id 去重统计 gate_status=pass 的 primary,并计算:goal_met(至少 5 条)、 partial(1~4 条)或 no_match(0 条)。完成守卫要求 finished 运行已经生成该业务结果。

10. 结束、超时与失败状态

11.1 模型循环结束

模型返回普通消息后生成 AgentResult,其中记录:

  • 最终文本;
  • 完整运行消息;
  • 已执行迭代数;
  • 工具调用数;
  • 已加载 Skill。

随后关闭异步 LLM 客户端并清理未完成异步任务。

运行日志和可视化产物在核心循环结束后发布。发布等待设置为 120 秒;超时或异常只记录 日志,不改变 AgentResult。当前使用线程池上下文执行发布,超时被捕获后退出线程池时 仍可能继续等待发布线程真正结束,因此 120 秒不是完整调用链的硬停止时间。

11.2 超时

超过单次运行总超时后:

  1. 取消 Agent 核心协程;
  2. 关闭异步客户端;
  3. 最多等待 5 秒清理后台异步任务,仍未完成的任务被取消;
  4. 抛出 TimeoutError
  5. 调度包装层将对应运行标记为 failed,保存超时原因。

11.3 异常

Agent 运行或结果判定期间出现未处理异常时,调度包装层把运行状态标记为 failed, 保存异常文本并继续向上抛出。

工具返回的结构化错误不是未处理异常,不会自动将运行标记为 failed。是否停止、切换 工具或输出“任务未完成(工具故障)”由模型判断。

11.4 当前完成与业务结果判定

技术完成要求 status=finished 且已生成业务结果。业务结果独立计算:

  • goal_met:有效且去重的 primary 至少 5 条;
  • partial:有效且去重的 primary 为 1~4 条;
  • no_match:没有有效 primary;
  • failed:超时、异常、未正确结束或模型显式标记技术失败。

partialno_match 都是正常完成,不触发自动重试。最终文本只用于展示,必须以状态 工具返回的 outcome_statusvalid_primary_count 为准。

11. 当前约束归属

约束 Prompt 驱动 代码硬约束
所有存储和状态工具复用预创建 run_id 是,相关工具均要求 run_id
形成 2~3 个根搜索词
每次搜索新增并保存搜索页
每条搜索结果新增独立候选并返回 ID
页码大于 1 自动归为翻页
根搜索不保留父搜索 ID
详情单批最多 8 条
画像单批最多 8 条
不使用视频理解 是,工具未注册
评分使用 0~1
V 按公式计算
最终分池仅 primary/rejected
primary 具备足够详情和双画像证据
运行最大 60 次模型迭代
单次运行总超时
技术完成与业务达标分开
自动执行最多一次,人工 force 可重跑

12. 当前执行伪代码

load contexts
  -> 过滤无有效点位或无有效参考视频的需求
  -> 按 score、grade、名称、ID 排序

for each context:
  run_id = prepare_or_reuse_run(status="running")
  if (finished or attempt_count >= 1) and not force:
      skip

  user_input = build_agent_input(context, run_id)
  agent = create_find_agent(model, temperature=0.2, max_iterations=60)

  try:
      repeat up to 60 iterations:
          response = LLM(system_prompt + message_history + tool_schemas)
          if response has no tool_calls:
              agent_result = response
              break

          for tool_call in response.tool_calls:
              tool_result = await execute(tool_call)
              append tool_result to message_history

      if 60 iterations exhausted:
          agent_result = LLM("provide best answer", tools=None)

      run = database.lookup_run(run_id)
      success = run.status == "finished" and run.outcome_status in {
          "goal_met", "partial", "no_match"
      }
      if not success:
          mark_run_failed("run_not_finished")
  except timeout or exception:
      mark_run_failed(error)
      raise
  finally:
      close async resources

  publish run logs
    -> report timeout after 120 seconds
    -> thread-pool shutdown may still wait for the publisher to exit