Explorar el Código

优化寻找agent

xueyiming hace 2 días
padre
commit
9179d98e45
Se han modificado 1 ficheros con 378 adiciones y 270 borrados
  1. 378 270
      agents/find_agent/PRD.md

+ 378 - 270
agents/find_agent/PRD.md

@@ -1,331 +1,439 @@
-# find_agent 产品需求文档(PRD)
+# find_agent 当前执行逻辑 PRD
 
 
-> 文档版本:v1.2
->
-> 基线日期:2026-07-28
->
-> Agent 定位:老年受众高潜抖音视频发现 Agent
+> 文档版本:v2.0  
+> 代码基线:2026-07-29  
+> 文档性质:As-Is 现状说明
 
 
-## 1. 文档目的与边界
+## 1. 文档范围
 
 
-本文只描述 `find_agent` 本身
+本文只描述 `find_agent` 当前已经存在的内部执行逻辑,包括
 
 
-- 当前职责、输入、输出和能力;
-- 当前使用的工具、判断规则和运行约束;
-- 当前存在的问题及需要优化的点。
+- Agent 实例配置;
+- 单次运行的输入构造;
+- 模型循环与工具调用;
+- 搜索、候选、证据和评估状态;
+- 持久化状态变化;
+- 正常结束、超时和失败判定;
+- Prompt 约束与代码硬约束的实际边界。
 
 
-本文不描述 SupplyAgent 的整体业务流程,不涉及需求分级、日批调度、跨 Agent 协作、
-下游生产发布、业务里程碑或平台级建设。
+## 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 组装:[agent.py](agent.py)
-- 核心提示词:[prompt/system_prompt.md](prompt/system_prompt.md)
-- 工具注册:[tools/__init__.py](tools/__init__.py)
-- 对外调用:[__init__.py](__init__.py)
+Agent 初始化时注册一个内置 `load_skill` 工具和 12 个 `find_agent` 专属工具。当前
+`find_agent` 没有预加载 Skill,正常执行依赖系统 Prompt 和已注册工具。
 
 
-## 2. Agent 定位
+## 3. 单次运行输入状态
 
 
-`find_agent` 根据一条明确的内容需求,从抖音候选中寻找同时满足以下条件的视频:
+### 3.1 上下文对象
 
 
-1. 与需求真实意图相关;
-2. 有证据支持其受众偏向较高年龄段;
-3. 具备可解释的分享价值。
+单次调度运行对应一个 `FindDemandContext`:
 
 
-Agent 对搜索词生成、候选补证、评分解释和最终分池负责。它不生产或改写视频,也不负责
-需求优先级、任务调度、内容发布及其他 Agent 的行为。
+| 字段 | 含义 |
+|---|---|
+| `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)` 去重。没有有效拓展点位的参考视频不会进入上下文;没有
+有效参考视频的需求不会生成运行上下文。
 
 
-## 3. 输入与输出
+上下文加载后的实际排序键为:
 
 
-### 3.1 输入
+`score 降序 → grade(S 在 A 前)→ demand_name → demand_grade_id`
 
 
-| 字段 | 必需性 | 含义 |
-|---|---|---|
-| `demand_word` | 必需 | 本次找片的需求词和意图边界 |
-| `seed_video_title` | 可选 | 已知相关视频标题,用于消除需求歧义 |
-| `relevant_points` | 可选 | 参考视频中与需求相关的灵感、目的或关键点 |
-| `reference_videos` | 可选 | 多个参考视频及各自相关点 |
-| `run_id` | 可选 | 已创建的发现运行标识;存在时必须复用 |
+因此当前实现先比较 `score`,只有同分时才比较 S/A 等级。
 
 
-输入信息不足时,Agent 可以继续搜索,但必须降低意图判断的置信度,不能用模型常识补全
-未提供的业务要求。
+### 3.2 运行预创建
 
 
-### 3.2 输出
+进入模型循环前,系统先按 `(biz_dt, demand_grade_id)` 预创建或复用
+`video_discovery_run`:
 
 
-Agent 最终输出以下内容:
+1. 新任务生成随机 `run_id`,初始状态为 `running`;
+2. 已有记录且未启用 `force` 时:
+   - 状态为 `finished`,跳过;
+   - 或该运行下已经存在任意候选记录,跳过;
+3. 已有记录但不满足跳过条件时,复用原 `run_id`,并将运行状态重置为 `running`;
+4. 启用 `force` 时复用已有 `run_id` 并重置运行输入,不新建第二条同需求运行。
 
 
-1. 一句话需求意图理解;
-2. `primary` 主推荐;
-3. `rejected` 淘汰候选及淘汰原因;
-4. Agent 实际执行的搜索记录;
-5. 缺失数据、画像冲突、未继续搜索项和接口错误。
+复用或强制运行不会清空原 `run_id` 下的搜索页和候选,也没有“执行代次”字段。新一次
+模型执行会继续读写同一组持久化记录。
 
 
-每条主推荐至少包含:
+### 3.3 模型用户消息
 
 
-- 标题、作者、抖音页面链接和 `aweme_id`;
-- 命中的需求点及相关性证据;
-- 原始分享数及可计算的分享效率;
-- 视频点赞用户年龄画像;
-- 作者粉丝年龄画像;
-- 分享动机;
-- `R / E / S / V` 整数分、置信度和主要限制。
+模型收到的用户消息包含:
 
 
-最终分池只允许 `primary / rejected`。`pending_evaluation` 仅是处理中的临时状态,不得
-出现在最终结果中。
+- 预创建的 `run_id`;
+- `demand_grade_id`;
+- `demand_word`;
+- 全部 `reference_videos` 及各自点位;
+- 直接复用给定 `run_id` 开始搜索、更新候选和管理状态的指令。
 
 
-## 4. 当前能力
+`relevant_points` 不传给模型,也不要求模型生成。调度程序在预创建运行时已经完成点位
+展平,并保存展平结果、完整参考视频快照以及兼容旧表结构的主参考视频字段。
 
 
-### 4.1 需求理解与搜索规划
+## 4. 内部执行状态机
 
 
-- 综合需求词、参考标题和相关点解释真实意图;
-- 默认生成 2~3 个语义不同的根搜索词;
-- 搜索词不要求逐字复用 `demand_word`;
-- 可根据高潜候选的话题、标题实体、作者和分页状态继续扩展;
-- 以新增有效候选和潜在信息价值决定是否继续搜索。
+### 4.1 逻辑阶段
 
 
-### 4.2 候选召回
+| 阶段 | 进入条件 | 内部动作 | 退出条件 |
+|---|---|---|---|
+| `CONTEXT_READY` | 已构造有效上下文 | 生成输入快照 | 准备运行记录 |
+| `RUNNING` | 运行记录已预创建或重置 | 启动日志与 ReAct 循环 | 模型请求工具或直接回答 |
+| `SEARCHING` | 模型调用召回工具 | 关键词、翻页、标签或作者扩展 | 搜索结果返回 |
+| `PERSISTING_SEARCH` | 搜索页已返回 | 自动新增搜索记录,并为本页每条结果新增候选记录 | 候选进入待评估态 |
+| `EVIDENCE_GATHERING` | 模型选择高潜候选 | 获取详情、视频画像、作者画像并标准化 | 模型认为证据足够 |
+| `EVALUATING` | 候选具备可用证据 | 生成 R/E/S/V、理由和最终分池 | 评估写入数据库 |
+| `FINAL_QUERY` | 候选与运行状态已保存 | 重新读取数据库最终状态 | 模型生成最终文本 |
+| `LOOP_DONE` | 模型返回不含工具调用的消息 | 结束 ReAct 循环 | 进入运行结果判定 |
+| `FAILED` | 外层异常、超时或结果侧失败 | 将运行标记为 `failed` | 本次执行结束 |
 
 
-- 支持内部抖音关键词搜索;
-- 支持 TikHub 独立搜索和分页;
-- 支持按作者扩展最热或最新作品;
-- 不同搜索词、页面和来源的候选按 `aweme_id` 去重;
-- TikHub 不可用时可退回内部搜索,并保留错误原因;
-- 每次搜索结果均可保存查询词、形成原因、分页状态和父搜索信息。
+逻辑阶段没有单独持久化字段。数据库只持久化运行、搜索页和候选三个层级的状态。
 
 
-### 4.3 候选证据补全
+### 4.2 持久化状态
 
 
-- 批量获取视频详情,核验标题、作者、话题、链接和互动数据;
-- 获取视频点赞用户画像;
-- 获取作者粉丝年龄画像;
-- 标准化不同年龄桶表达;
-- 记录画像缺失、接口失败及视频画像与作者画像冲突;
-- 先使用搜索结果进行低成本预筛,再为高潜候选补充详情和画像。
+运行状态:
 
 
-当前没有以下证据:
+- `running`
+- `finished`
+- `failed`
 
 
-- 视频真实转发用户年龄画像;
-- 分年龄曝光、播放、完播和观看时长;
-- 视频画面、语音或字幕理解结果;
-- 同题材、相近发布时间下的标准化传播基线。
+搜索页状态:
 
 
-### 4.4 评分与分池
+- `success`
+- `failed`
 
 
-Agent 对每个候选独立判断
+候选状态
 
 
-- `R`:需求相关性;
-- `E`:老年受众倾向;
-- `S`:分享价值。
+- `pending_evaluation`
+- `primary`
+- `rejected`
 
 
-综合价值为:
+数据库兼容读取旧值 `unreviewed`,读取时统一映射为 `pending_evaluation`。
 
 
-`V = 100 × R^0.40 × E^0.35 × S^0.25`
+## 5. ReAct 循环逻辑
 
 
-`V` 用于保持排序一致,不替代证据判断。只有 `R / E / S` 三项均成立的候选才能进入
-`primary`;任一项不成立时进入 `rejected`。
+每轮执行顺序为:
 
 
-当前分池由模型根据提示词和证据作出,保存工具只保存结果,不重新计算分数或改变分池。
+1. 将系统 Prompt、历史消息和当前工具定义发送给模型;
+2. 模型返回普通消息或一个及以上工具调用;
+3. 无工具调用时,当前普通消息立即成为 `AgentResult.content`,循环结束;
+4. 有工具调用时,按模型返回顺序逐个执行;
+5. 每个工具结果写入运行日志并追加为 `tool` 消息;
+6. 下一轮模型基于完整消息历史继续决策。
 
 
-### 4.5 状态保存与完成控制
+同一轮中的多个工具调用不是并行执行,而是顺序执行。工具返回 JSON 中即使包含
+`error`,循环框架也只把它标记为工具错误并交还模型,不自动重试、不自动失败,也不
+自动切换替代工具。
 
 
-- 创建或复用 `run_id`;
-- 保存每个搜索页和去重后的候选;
-- 批量保存候选证据、评分、理由和分池;
-- 查询已保存的搜索与候选状态;
-- 通过数据库审计检查搜索、证据、评估和最终状态;
-- completion guard 强制最后阶段满足:
+### 5.1 非法工具参数恢复
 
 
-`搜索页已保存 → 证据已获取 → 评估已保存 → 审计通过 → 查询最终状态 → 输出报告`
+模型供应方返回 `MALFORMED_FUNCTION_CALL` 时:
 
 
-最终报告只读取数据库最终状态,不通过文字反向修改候选分池。报告中的主推荐和淘汰
-候选必须与数据库状态一致。
+1. LLM 客户端最多进行 3 次内部重试,即一次原请求加 3 次重试;
+2. 重试时温度降为 `0`,关闭并行工具调用,并附加“只调用一个必要工具”的修正指令;
+3. 连续失败后向 AgentLoop 抛出 `MalformedFunctionCallError`;
+4. AgentLoop 追加一条参数修正消息,然后消耗下一次 Agent 迭代继续执行。
 
 
-当 Agent 结合任务上下文、替代方案和工具反馈判断任务已经无法继续时,应停止无效重试,
-输出以 `任务未完成(工具故障)` 开头的失败摘要。完成守卫只识别该声明,不解析工具
-返回结构或错误文案,也不替 Agent 判断错误是否可恢复。
+### 5.2 最大迭代结束
 
 
-## 5. 当前判断规则
+完成 60 次迭代后,框架追加“立即给出当前最佳答案”的消息,再发起一次不携带工具定义的
+模型请求。该请求只能生成文本,不能继续完成搜索、保存或最终状态查询。
 
 
-### 5.1 需求相关性是准入条件
+框架不会在模型输出最终文本前检查运行状态或数据库一致性。
 
 
-- 搜索词命中不等于内容相关;
-- 高分享或受众偏老不能弥补低相关;
-- 参考标题和相关点用于理解意图,不要求候选逐字匹配。
+## 6. 搜索与候选召回逻辑
 
 
-### 5.2 年龄证据按强度使用
+### 6.1 搜索计划
 
 
-证据优先级为
+搜索词、搜索顺序和是否扩展由模型决定。系统 Prompt 当前要求
 
 
-1. 视频点赞用户年龄画像;
-2. 作者粉丝年龄画像;
-3. 标题、描述、话题和详情文本体现的内容适配特征;
-4. 题材、人物或作者形象带来的直觉。
+- 从需求、参考标题和相关点形成 2~3 个语义不同的根搜索;
+- 根搜索来源使用 `demand`、`seed`、`point` 或 `mixed`;
+- 标签、作者和翻页扩展使用 `tag`、`author` 或 `pagination`;
+- 尽量形成 5 条 `decision_bucket=primary` 的通过视频;
+- 优先验证语义不同的有效根搜索,并按信息价值决定是否翻页或扩展标签;
+- 剩余前沿不再可能改变候选判断、排序或置信度时停止。
 
 
-第 4 类不能单独支持老年倾向。视频画像与作者画像冲突时,以视频画像为主并降低置信度。
-明确覆盖 50 岁及以上的年龄桶才属于直接老年信号;只有 40 岁以上数据时,只能表述为
-成熟人群代理信号。
+以上搜索策略由 Prompt 驱动,AgentLoop 不包含固定搜索计划器。
 
 
-### 5.3 分享证据与年龄证据不能互相替代
+### 6.2 搜索来源
 
 
-- `share_count` 说明传播规模,不说明分享者年龄;
-- 点赞用户或作者粉丝年龄画像说明受众倾向,不说明已经发生转发;
-- 当前只能推断“老年人可能愿意分享”,不能声称已观察到老年分享者;
-- 分享价值同时参考分享规模、分享效率和内容动机。
+| 来源 | 当前行为 |
+|---|---|
+| `douyin_search` | 内部关键词搜索;返回标题、作者、点赞、评论、分享和分页游标 |
+| `douyin_search_tikhub` | TikHub 独立搜索;额外返回话题、收藏、播放、时长及完整分页状态 |
+| `douyin_user_videos` | 按作者 `sec_uid` 获取最热或最新作品 |
 
 
-### 5.4 缺失不是负证据
+内部关键词搜索和作者作品接口在进程内分别执行至少 `10.1` 秒的调用间隔控制;TikHub
+搜索执行至少 `1` 秒的调用间隔控制。
 
 
-- 数据缺失或接口失败应标记为未知;
-- 未知会降低置信度,但不自动计为零分;
-- 强反证优先于多个弱正向线索;
-- 不得为了达到推荐数量而降低准入标准。
+TikHub 翻页需要原样复用上一页的 `next_cursor`、`search_id`、`backtrace`。三个来源的
+游标彼此独立。
 
 
-### 5.5 推荐集合需要新增价值
+### 6.3 搜索页持久化
 
 
-- 高度重复的视频只保留证据更强的一条;
-- 价值接近时优先覆盖不同需求点和分享动机;
-- 合理搜索后允许少于 5 条或返回空结果。
+搜索工具在外部接口返回后自动完成持久化。当前处理规则为:
 
 
-## 6. 当前工具
+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` 拼入结果。
 
 
-| 工具 | 当前用途 | 关键限制 |
-|---|---|---|
-| `douyin_search` | 内部关键词召回 | 搜索结果不是最终事实 |
-| `douyin_search_tikhub` | TikHub 搜索与分页 | 翻页必须复用供应方分页参数 |
-| `douyin_user_videos` | 扩展作者作品 | 作品仍需逐条补证和判断 |
-| `douyin_detail` | 批量核验视频详情 | 单次最多 8 条 |
-| `get_content_fans_portrait` | 获取单条视频点赞用户画像 | 不是分享用户画像 |
-| `get_account_fans_portrait` | 获取作者粉丝画像 | 只代表账号受众先验 |
-| `batch_fetch_portraits` | 批量获取视频和作者画像 | 单次最多 8 条 |
-| `normalize_age_portraits` | 标准化年龄桶 | 不负责业务评分 |
-| `create_video_discovery_run` | 创建或复用运行状态 | 传入 `run_id` 时必须复用 |
-| `record_video_search_page` | 保存搜索页并合并候选 | 每次搜索后均需调用 |
-| `batch_save_video_candidate_evaluations` | 保存证据、评分和分池 | 只接受 `primary / rejected` |
-| `audit_video_discovery_run` | 审计 Agent 最终状态 | `can_finish=true` 才能完成 |
-| `query_video_discovery_state` | 恢复或读取最终状态 | 最终查询必须晚于成功审计 |
+## 7. 证据获取逻辑
 
 
-`qwen_video_analyze` 未注册,不属于当前 Agent 能力。
+### 7.1 详情核验
 
 
-## 7. 当前运行约束
+`douyin_detail` 单次最多处理 8 个视频 ID,用于更新:
 
 
-| 项目 | 当前设置 |
-|---|---|
-| 模型 | 固定为 `google/gemini-3-flash-preview` |
-| 最大迭代轮次 | 60 |
-| 温度 | 0.2 |
-| 单批详情/画像候选数 | 最多 8 条 |
-
-`create_find_agent(..., model=...)` 和 `run_find_agent(..., model=...)` 虽然暴露了模型参数,
-当前工厂仍固定使用上述模型,传入参数不会生效。
-
-## 8. 当前实现评价
-
-| 能力 | 状态 | 当前判断 |
-|---|---|---|
-| 需求理解 | 可用 | 能结合需求词、参考标题和相关点形成搜索假设 |
-| 多源搜索 | 可用 | 内部搜索、TikHub 和作者作品均已注册 |
-| 候选去重 | 可用 | 支持跨词、跨页和跨来源按 `aweme_id` 合并 |
-| 详情与画像 | 可用但有缺口 | 支持详情、双侧画像和年龄标准化,画像可能缺失 |
-| 内容理解 | 受限 | 仅使用文本、互动和画像,不使用视频理解 |
-| 评分与分池 | 部分可用 | 规则完整,但主要依赖模型遵守长提示词 |
-| 状态保存 | 可用 | 可保存运行、搜索页和候选状态 |
-| 完成审计 | 业务侧 | 由工具 `audit_video_discovery_run` + `run_outcome` 判定;框架无完成守卫 |
-| 故障终止 | 已接入 | Agent 明确声明工具故障时允许失败结束 |
-| 最终一致性 | 部分可用 | 报告只读并接受校验,尚无单一事务性最终化入口 |
-| 运行恢复 | 部分可用 | 能查询旧状态,但缺少执行代次和自动恢复机制 |
-| 可观测性 | 不完整 | 有日志和基础计数,缺少 Agent 级质量与成本指标 |
-
-## 9. 需要优化的点
-
-### 9.1 P0:提高 Agent 的确定性与一致性
-
-1. **增加事务性最终化**
-   - 新增单一 `finalize_video_discovery_run` 能力;
-   - 仅允许最终化当前运行已召回且已评估的候选;
-   - 在一个事务内校验并写入候选分池、计数、完成状态和最终摘要;
-   - 最终化失败必须显式返回错误并支持安全重试。
-
-2. **将关键约束从长提示词下沉到程序**
-   - 用结构化节点控制搜索、补证、评估、审计和最终输出;
-   - 对必需证据、合法状态和工具调用顺序做程序校验;
-   - 保留 LLM 对意图、搜索词、语义相关性和解释的判断空间。
-
-3. **结构化模型输出**
-   - 为搜索计划、候选评估和最终结果定义 JSON Schema;
-   - 校验 `R / E / S` 取值、必填证据、置信度和分池;
-   - 由程序计算 `V`,避免模型计算漂移;
-   - 禁止模型输出未召回的候选 ID。
-
-4. **修复模型配置**
-   - 让显式传入的 `model` 参数真正生效;
-   - 保存实际使用的模型、Prompt 和评分策略版本;
-   - 避免固定依赖预览模型。
-
-5. **补齐 Agent 契约测试**
-   - 覆盖未保存搜索页、证据晚于评估、审计失败、旧状态查询和报告分池不一致;
-   - 覆盖幻觉候选 ID、非法分池、缺失证据;
-   - 用固定样例验证相关性闸门、年龄证据层级和未知数据处理。
-
-### 9.2 P1:提高搜索与判断质量
-
-1. **优化搜索前沿选择**
-   - 结构化记录每个搜索假设的预期价值、实际新增候选和耗时;
-   - 根据边际新增率决定翻页、标签扩展和作者扩展;
-   - 减少同义词重复搜索及低价值工具调用。
-
-2. **优化候选预筛与补证**
-   - 将低成本预筛规则结构化;
-   - 优先为可能改变分池的候选补充详情和画像;
-   - 对重复详情和画像增加短期缓存;
-   - 记录每项缺失证据对置信度的具体影响。
-
-3. **建立评分评测集**
-   - 建立覆盖不同题材的人工标注样例;
-   - 分别评估 `R / E / S`,避免只看最终分池;
-   - 统计 `primary / rejected` 混淆情况;
-   - 按模型、Prompt 和策略版本回放对比。
-
-4. **增强 Agent 可观测性**
-   - 记录搜索新增率、候选漏斗、证据缺失率、工具失败率和单次调用成本;
-   - 区分模型判断失败、外部接口失败、持久化失败和完成校验失败;
-   - 输出本次使用的模型、Prompt、策略和数据源版本。
-
-5. **改进运行恢复**
-   - 为同一 `run_id` 增加执行代次;
-   - 恢复时明确区分已完成步骤、可复用证据和需要重试的失败项;
-   - 避免新一轮执行混用已过期的搜索或候选判断。
-
-### 9.3 P2:升级 Agent 可用证据
-
-1. 接入真实转发用户年龄分布和样本量;
-2. 接入分年龄曝光、有效播放、完播率和观看时长;
-3. 建立同题材、同发布时间窗口的传播基线;
-4. 增加评论中的提醒、家庭沟通、收藏和求链接等结构化意图信号;
-5. 在获得明确授权和完整验证前,继续保持“不使用视频理解”的能力边界;
-6. 将新证据的计算规则、置信度上限和冲突处理策略版本化。
-
-## 10. Agent 验收标准
-
-一次 `find_agent` 运行满足以下条件,才视为 Agent 自身完成:
-
-1. 已创建或复用唯一 `run_id`;
-2. 实际调用的搜索页均已保存;
-3. 候选已按 `aweme_id` 去重;
-4. 主推荐已尝试获取详情和双侧年龄画像;
-5. 年龄画像已标准化,缺失和冲突已披露;
-6. 每个已评估候选具有合法分池及对应理由;
-7. 最终不存在 `pending_evaluation` 或其他非法分池;
-8. 数据库审计返回 `can_finish=true`;
-9. 审计后已重新查询最终状态;
-10. 最终报告与数据库 `primary / rejected` 完全一致;
-11. 不使用未注册的视频理解能力或编造缺失证据;
-12. 推荐不足 5 条时不降低质量标准。
-
-若工具故障导致任务无法继续,则不适用上述成功条件,但必须满足:
-
-1. Agent 已根据上下文判断继续调用无法产生有效进展;
-2. Agent 已停止重复调用失败工具;
-3. 最终摘要明确标记 `任务未完成(工具故障)`;
-4. 摘要包含失败工具、原始错误、已完成内容和未完成内容;
-5. 不输出看似有效的推荐结果。
+- 标题和描述;
+- 作者信息;
+- 话题标签;
+- 页面链接;
+- 播放、点赞、评论、收藏和分享数据;
+- 发布时间和视频时长。
+
+详情工具返回证据,不直接修改候选数据库。模型需要再次调用候选评估保存工具才能写入。
+
+### 7.2 年龄画像
+
+年龄证据包含两个独立来源:
+
+- 视频点赞用户画像:内容侧直接证据;
+- 作者粉丝画像:账号侧先验。
+
+`batch_fetch_portraits` 单次最多处理 8 个候选,按输入顺序逐条请求内容画像;设置
+`fetch_account_portrait=true` 时同时请求作者画像。每条结果自动生成标准化年龄结果。
+
+单条画像工具只返回原始画像,需要额外调用 `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` 更新同视频的其他搜索记录。候选更新工具
+只修改 `video_discovery_candidate`;运行状态由独立工具修改。
+
+## 9. 结束与输出流程
+
+Prompt 规定的正常结束流程为:
+
+`停止搜索 → 获取必要证据 → 按 candidate_id 更新已作出的候选判断
+→ 将运行设为 finished → 输出`
+
+`query_video_discovery_state` 保留为按需恢复和查看已持久化状态的工具,不是正常结束的
+强制步骤。AgentLoop 没有完成守卫:模型任何一轮只要返回不含工具调用的普通消息,就会
+立即结束。
+
+## 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 当前成功判定
+
+模型循环返回后,调度侧的当前成功判定只检查:
+
+`该 run_id 下是否存在至少一条候选记录`
+
+只要存在任意候选,即判定本次执行成功。该候选可以是:
+
+- `pending_evaluation`;
+- `primary`;
+- `rejected`;
+- 仅由搜索页自动创建、尚未补证的候选。
+
+成功判定不检查:
+
+- 运行状态是否为 `finished`;
+- 是否存在 `primary`;
+- 最终文本是否符合输出契约。
+
+如果一条候选都不存在,调度侧将运行标记为 `failed`,失败原因为 `no_candidates`。模型
+最终文本只作为失败原因预览附加保存,不参与成功判定。
+
+## 11. 当前约束归属
+
+| 约束 | Prompt 驱动 | 代码硬约束 |
+|---|---:|---:|
+| 所有存储和状态工具复用预创建 `run_id` | 是 | 是,相关工具均要求 `run_id` |
+| 形成 2~3 个根搜索词 | 是 | 否 |
+| 每次搜索新增并保存搜索页 | 否 | 是 |
+| 每条搜索结果新增独立候选并返回 ID | 否 | 是 |
+| 页码大于 1 自动归为翻页 | 否 | 是 |
+| 根搜索不保留父搜索 ID | 否 | 是 |
+| 详情单批最多 8 条 | 否 | 是 |
+| 画像单批最多 8 条 | 否 | 是 |
+| 不使用视频理解 | 是 | 是,工具未注册 |
+| 评分使用 `0~1` | 是 | 否 |
+| `V` 按公式计算 | 是 | 否 |
+| 最终分池仅 `primary/rejected` | 是 | 是 |
+| `primary` 具备足够详情和双画像证据 | 是 | 否 |
+| 运行最大 60 次模型迭代 | 否 | 是 |
+| 单次运行总超时 | 否 | 是 |
+| 调度成功必须存在候选 | 否 | 是 |
+
+## 12. 当前执行伪代码
+
+```text
+load contexts
+  -> 过滤无有效点位或无有效参考视频的需求
+  -> 按 score、grade、名称、ID 排序
+
+for each context:
+  run_id = prepare_or_reuse_run(status="running")
+  if finished or has_any_candidate 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)
+
+      success = database.has_any_candidate(run_id)
+      if not success:
+          mark_run_failed("no_candidates")
+  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
+```