# 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` | 参考视频对应的有效拓展点位 |

点位只接受 `inspiration`、`purpose`、`key` 三种类型。空点位被丢弃，同一视频内按
`(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 个语义不同的根搜索；
- 根搜索来源使用 `demand`、`seed`、`point` 或 `mixed`；
- 标签、作者和翻页扩展使用 `tag`、`author` 或 `pagination`；
- 尽量形成 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_cursor`、`search_id`、`backtrace`。三个来源的
游标彼此独立。

### 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`：超时、异常、未正确结束或模型显式标记技术失败。

`partial` 和 `no_match` 都是正常完成，不触发自动重试。最终文本只用于展示，必须以状态
工具返回的 `outcome_status` 和 `valid_primary_count` 为准。

## 11. 当前约束归属

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

## 12. 当前执行伪代码

```text
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
```
