# find_agent 当前执行逻辑 PRD > 文档版本:v2.0 > 代码基线:2026-07-29 > 文档性质: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`,跳过; - 或该运行下已经存在任意候选记录,跳过; 3. 已有记录但不满足跳过条件时,复用原 `run_id`,并将运行状态重置为 `running`; 4. 启用 `force` 时复用已有 `run_id` 并重置运行输入,不新建第二条同需求运行。 复用或强制运行不会清空原 `run_id` 下的搜索页和候选,也没有“执行代次”字段。新一次 模型执行会继续读写同一组持久化记录。 ### 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,用于更新: - 标题和描述; - 作者信息; - 话题标签; - 页面链接; - 播放、点赞、评论、收藏和分享数据; - 发布时间和视频时长。 详情工具返回证据,不直接修改候选数据库。模型需要再次调用候选评估保存工具才能写入。 ### 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 ```