supeng hace 6 horas
padre
commit
603a22a679

+ 0 - 444
GOAL_EXPLAINED.md

@@ -1,444 +0,0 @@
-# Goal 系统详解
-
-## 什么是 Goal?
-
-**Goal(目标)** 是这个 Agent 框架中的**计划管理系统**,用于帮助 Agent 组织和追踪复杂任务的执行过程。
-
-### 核心概念
-
-```
-Goal = 执行计划中的一个目标节点
-GoalTree = 目标树,管理整个执行计划的层级结构
-```
-
-可以把 Goal 理解为:
-- **任务分解**:将大任务拆分成小目标
-- **工作记忆**:记录当前在做什么、做完了什么
-- **进度追踪**:跟踪每个目标的状态和结果
-
-## 为什么需要 Goal?
-
-### 问题场景
-
-当 Agent 执行复杂任务时,会遇到这些问题:
-
-1. **任务复杂**:一个大任务需要多个步骤
-2. **容易迷失**:执行过程中忘记原本要做什么
-3. **难以追踪**:不知道哪些完成了,哪些还没做
-4. **缺乏规划**:没有清晰的执行路径
-
-### Goal 的解决方案
-
-```
-用户任务: "帮我实现一个用户登录功能"
-
-没有 Goal:
-Agent → 直接开始写代码 → 可能遗漏测试、文档等
-
-有 Goal:
-Agent → 创建计划:
-  1. 设计数据库表结构
-  2. 实现登录接口
-    2.1 实现密码加密
-    2.2 实现 JWT 生成
-    2.3 实现登录验证
-  3. 编写单元测试
-  4. 编写 API 文档
-
-→ 按计划逐步执行 → 完整、有序、可追踪
-```
-
-## Goal 的结构
-
-### Goal 对象
-
-```python
-@dataclass
-class Goal:
-    id: str                    # 唯一 ID(如 "1", "2", "3")
-    description: str           # 目标描述(如 "实现登录接口")
-    reason: str               # 创建理由(为什么要做这个)
-    parent_id: Optional[str]  # 父目标 ID(构建层级关系)
-    status: GoalStatus        # 状态:pending/in_progress/completed/abandoned
-    summary: Optional[str]    # 完成时的总结(记录关键结论)
-
-    # 统计信息
-    self_stats: GoalStats     # 自身统计(消息数、token、成本)
-    cumulative_stats: GoalStats  # 累计统计(包含所有子目标)
-```
-
-### GoalTree 目标树
-
-```python
-@dataclass
-class GoalTree:
-    mission: str              # 总任务描述
-    goals: List[Goal]         # 所有目标的扁平列表
-    current_id: Optional[str] # 当前焦点目标 ID
-```
-
-### 层级结构示例
-
-```
-GoalTree
-├── mission: "实现用户登录功能"
-├── current_id: "2.1"
-└── goals:
-    ├── Goal(id="1", description="设计数据库表结构", status="completed")
-    ├── Goal(id="2", description="实现登录接口", status="in_progress")
-    │   ├── Goal(id="3", parent_id="2", description="实现密码加密", status="completed")
-    │   ├── Goal(id="4", parent_id="2", description="实现 JWT 生成", status="in_progress") ← 当前焦点
-    │   └── Goal(id="5", parent_id="2", description="实现登录验证", status="pending")
-    ├── Goal(id="6", description="编写单元测试", status="pending")
-    └── Goal(id="7", description="编写 API 文档", status="pending")
-```
-
-显示为:
-
-```
-[Mission] 实现用户登录功能
-
-1. [✓] 设计数据库表结构
-2. [→] 实现登录接口
-   2.1 [✓] 实现密码加密
-   2.2 [→] 实现 JWT 生成  ← 当前焦点
-   2.3 [ ] 实现登录验证
-3. [ ] 编写单元测试
-4. [ ] 编写 API 文档
-```
-
-## Goal 工具的使用
-
-Agent 通过 `goal` 工具来管理计划:
-
-### 1. 创建目标
-
-```python
-# 创建顶层目标
-goal(add="设计方案, 实现代码, 编写测试")
-
-# 创建子目标
-goal(add="实现接口, 实现逻辑", under="2")
-
-# 在某个目标后添加同级目标
-goal(add="编写文档", after="3")
-```
-
-### 2. 切换焦点
-
-```python
-# 聚焦到目标 1,开始执行
-goal(focus="1")
-
-# 聚焦到子目标
-goal(focus="2.1")
-```
-
-### 3. 完成目标
-
-```python
-# 完成当前目标,记录关键结论
-goal(done="方案确定使用 JWT 认证,安全性高")
-
-# 完成并切换到下一个
-goal(done="接口实现完成", focus="2")
-```
-
-### 4. 放弃目标
-
-```python
-# 放弃不可行的目标
-goal(abandon="方案 A 需要 Redis,但环境不支持")
-```
-
-## Goal 的工作流程
-
-### 典型执行流程
-
-```
-1. 用户提交任务
-   ↓
-2. Agent 分析任务,创建初步计划
-   goal(add="调研方案, 实现功能, 测试验证")
-   ↓
-3. 聚焦到第一个目标
-   goal(focus="1")
-   ↓
-4. 执行目标(调用工具、分析结果)
-   ↓
-5. 完成目标,记录结论
-   goal(done="确定使用 OpenPose 方案")
-   ↓
-6. 切换到下一个目标
-   goal(focus="2")
-   ↓
-7. 如果需要,创建子目标
-   goal(add="设计接口, 实现代码", under="2")
-   ↓
-8. 重复 3-7,直到所有目标完成
-```
-
-### 动态调整
-
-```
-执行过程中发现新问题:
-goal(add="修复 Bug", after="2")
-
-发现某个方案不可行:
-goal(abandon="方案 A 性能不达标")
-goal(add="尝试方案 B", after="1")
-```
-
-## Goal 的作用
-
-### 1. 任务分解
-
-将复杂任务拆分成可管理的小目标:
-
-```
-大任务: "开发一个博客系统"
-↓
-Goal Tree:
-1. 设计数据库
-   1.1 设计用户表
-   1.2 设计文章表
-   1.3 设计评论表
-2. 实现后端 API
-   2.1 用户管理
-   2.2 文章管理
-   2.3 评论管理
-3. 实现前端页面
-4. 部署上线
-```
-
-### 2. 进度追踪
-
-清楚知道当前进度:
-
-```
-[Mission] 开发博客系统
-
-1. [✓] 设计数据库
-   1.1 [✓] 设计用户表
-   1.2 [✓] 设计文章表
-   1.3 [✓] 设计评论表
-2. [→] 实现后端 API  ← 当前在这里
-   2.1 [✓] 用户管理
-   2.2 [→] 文章管理  ← 具体在这个子任务
-   2.3 [ ] 评论管理
-3. [ ] 实现前端页面
-4. [ ] 部署上线
-
-进度: 5/10 完成 (50%)
-```
-
-### 3. 工作记忆
-
-记录关键结论,避免重复工作:
-
-```
-Goal 1: 调研数据库方案
-Summary: "选择 PostgreSQL,支持 JSON 字段,性能好"
-
-Goal 2: 实现用户认证
-Summary: "使用 JWT + Redis 存储 token,过期时间 7 天"
-
-→ 后续目标可以参考这些结论
-```
-
-### 4. 上下文管理
-
-系统会定期将当前计划注入到 Agent 的上下文中:
-
-```
-System: 当前计划状态:
-[Mission] 开发博客系统
-1. [✓] 设计数据库 (已完成: 选择 PostgreSQL)
-2. [→] 实现后端 API (进行中)
-   2.1 [✓] 用户管理 (已完成: JWT 认证)
-   2.2 [→] 文章管理 (当前焦点)
-   2.3 [ ] 评论管理
-...
-
-Agent: 好的,我现在在做文章管理,已经完成了用户管理...
-```
-
-### 5. 可视化和调试
-
-通过 API Server 可以可视化查看 Goal Tree:
-
-```bash
-python api_server.py
-# 访问 http://localhost:8000/api/traces/{trace_id}
-```
-
-可以看到:
-- 目标的层级结构
-- 每个目标的状态
-- 执行时间和成本
-- 工具调用序列
-
-## 实际案例
-
-### 案例 1:简单任务(单个 Goal)
-
-```python
-任务: "将 CSV 文件转换为 JSON"
-
-Goal Tree:
-1. [→] 将 CSV 转换为 JSON
-
-执行:
-- 读取 CSV 文件
-- 解析数据
-- 转换为 JSON
-- 保存文件
-- goal(done="转换完成,输出到 output.json")
-```
-
-### 案例 2:中等任务(多个 Goal)
-
-```python
-任务: "分析销售数据并生成报告"
-
-Goal Tree:
-1. [✓] 读取销售数据
-2. [✓] 数据清洗和预处理
-3. [→] 数据分析
-   3.1 [✓] 计算总销售额
-   3.2 [→] 分析销售趋势
-   3.3 [ ] 识别热销产品
-4. [ ] 生成可视化图表
-5. [ ] 编写分析报告
-```
-
-### 案例 3:复杂任务(深层嵌套)
-
-```python
-任务: "实现一个完整的用户认证系统"
-
-Goal Tree:
-1. [✓] 需求分析和方案设计
-2. [→] 后端实现
-   2.1 [✓] 数据库设计
-       2.1.1 [✓] 设计用户表
-       2.1.2 [✓] 设计权限表
-   2.2 [→] API 实现
-       2.2.1 [✓] 注册接口
-       2.2.2 [→] 登录接口
-       2.2.3 [ ] 登出接口
-       2.2.4 [ ] 密码重置接口
-   2.3 [ ] 安全加固
-3. [ ] 前端实现
-4. [ ] 测试
-5. [ ] 部署
-```
-
-## 最佳实践
-
-### 1. 先规划再执行
-
-```python
-# ✅ 好的做法
-goal(add="调研方案, 实现功能, 测试验证")
-goal(focus="1")
-# 开始执行...
-
-# ❌ 不好的做法
-# 直接开始执行,没有计划
-```
-
-### 2. 记录关键结论
-
-```python
-# ✅ 好的做法
-goal(done="选择 PostgreSQL,支持 JSON 字段,性能优于 MySQL")
-
-# ❌ 不好的做法
-goal(done="调研完成")  # 没有信息量
-```
-
-### 3. 保持焦点在具体目标
-
-```python
-# ✅ 好的做法
-goal(add="设计接口, 实现代码", under="2")
-goal(focus="2.1")  # 聚焦到具体的子目标
-
-# ❌ 不好的做法
-goal(focus="2")  # 聚焦在父目标,太宽泛
-```
-
-### 4. 灵活调整计划
-
-```python
-# 执行中发现新问题
-goal(add="修复性能问题", after="3")
-
-# 发现方案不可行
-goal(abandon="方案 A 不支持并发")
-goal(add="尝试方案 B", after="1")
-```
-
-### 5. 简单任务不过度拆分
-
-```python
-# ✅ 简单任务
-goal(add="读取文件并统计行数")
-# 一个目标就够了
-
-# ❌ 过度拆分
-goal(add="打开文件, 读取内容, 统计行数, 关闭文件")
-# 太细碎,没必要
-```
-
-## 总结
-
-### Goal 是什么?
-
-- **计划管理系统**:帮助 Agent 组织和追踪任务执行
-- **工作记忆**:记录当前在做什么、做完了什么
-- **层级结构**:支持任务分解和嵌套
-
-### Goal 的核心价值
-
-1. **任务分解**:将复杂任务拆分成可管理的小目标
-2. **进度追踪**:清楚知道完成了什么、还剩什么
-3. **工作记忆**:记录关键结论,避免重复工作
-4. **上下文管理**:系统自动注入计划状态到 Agent
-5. **可视化调试**:通过 API Server 查看执行过程
-
-### 何时使用 Goal?
-
-- ✅ 复杂任务需要多个步骤
-- ✅ 需要清晰的执行计划
-- ✅ 需要追踪进度和结果
-- ✅ 任务可能需要动态调整
-
-- ❌ 非常简单的单步任务
-- ❌ 不需要计划的即时响应
-
-### 类比理解
-
-```
-Goal 就像是:
-
-📋 待办清单(Todo List)
-- 记录要做的事情
-- 勾选完成的项目
-- 添加新的任务
-
-🗺️ 导航地图
-- 规划路线
-- 追踪当前位置
-- 调整路径
-
-🧠 工作记忆
-- 记住当前在做什么
-- 记录重要结论
-- 避免迷失方向
-```
-
----
-
-**Goal 让 Agent 像人类一样有计划地工作,而不是盲目执行!** 🎯

+ 0 - 370
HOW_TO_BUILD_AGENT.md

@@ -1,370 +0,0 @@
-# 如何快速实现自己的 Agent - 完整指南
-
-## 📚 文档索引
-
-1. **[QUICKSTART.md](./QUICKSTART.md)** - 快速上手指南
-   - 三步快速开始
-   - 核心组件详解
-   - 实战案例
-   - 高级功能
-   - 最佳实践
-
-2. **[examples/production_template/](./examples/production_template/)** - 生产级模板
-   - 完整的生产级代码
-   - 配置管理
-   - 错误处理
-   - 日志记录
-   - 部署指南
-
-3. **[examples/content_finder/](./examples/content_finder/)** - 实战案例
-   - 内容寻找 Agent 完整实现
-   - 工具、记忆、技能完整示例
-   - 可运行的演示代码
-
-## 🚀 快速开始(5分钟)
-
-### 1. 安装依赖
-
-```bash
-pip install -r requirements.txt
-pip install dbutils pymysql  # browser 模块依赖
-```
-
-### 2. 配置环境
-
-```bash
-cp .env.example .env
-# 编辑 .env 填入 OPENROUTER_API_KEY
-```
-
-### 3. 运行模板
-
-```bash
-python examples/production_template/run.py
-```
-
-## 📖 学习路径
-
-### 初学者
-
-1. **阅读** [QUICKSTART.md](./QUICKSTART.md) 的"三步快速开始"部分
-2. **运行** `examples/content_finder/demo.py` 查看效果
-3. **修改** demo.py 中的任务描述,尝试不同功能
-4. **创建** 第一个自定义工具
-
-### 进阶开发者
-
-1. **学习** [QUICKSTART.md](./QUICKSTART.md) 的"核心组件"和"高级功能"
-2. **研究** `examples/production_template/` 的代码结构
-3. **实现** 自己的业务工具和 Skills
-4. **部署** 到生产环境
-
-### 生产使用
-
-1. **复制** `examples/production_template/` 作为起点
-2. **替换** 示例工具为实际业务工具
-3. **编写** 业务相关的 Skills
-4. **配置** 监控、日志、告警
-5. **部署** 到生产环境
-
-## 🎯 核心概念
-
-### Agent = Runner + Tools + Skills
-
-```
-┌─────────────────────────────────────┐
-│         AgentRunner                 │
-│  ┌──────────┐  ┌──────────┐        │
-│  │   LLM    │  │  Trace   │        │
-│  │  Call    │  │  Store   │        │
-│  └──────────┘  └──────────┘        │
-└─────────────────────────────────────┘
-           │
-    ┌──────┴──────┐
-    │             │
-┌───▼───┐    ┌───▼────┐
-│ Tools │    │ Skills │
-│       │    │        │
-│ 工具   │    │ 技能   │
-│ 能力   │    │ 知识   │
-└───────┘    └────────┘
-```
-
-### 工具 (Tools)
-
-- **定义**:Agent 可以调用的函数
-- **作用**:扩展 Agent 的能力(读文件、调 API、查数据库等)
-- **实现**:使用 `@tool` 装饰器
-
-### 技能 (Skills)
-
-- **定义**:Markdown 格式的知识文档
-- **作用**:指导 Agent 如何工作(何时用什么工具、如何处理任务)
-- **实现**:创建 `.md` 文件,使用 YAML frontmatter
-
-### 执行追踪 (Trace)
-
-- **定义**:一次完整的 Agent 执行过程
-- **作用**:记录所有消息、工具调用、结果
-- **用途**:调试、回溯、续跑
-
-## 💡 最佳实践
-
-### 1. 工具设计
-
-```python
-@tool(description="简洁清晰的描述")
-async def my_tool(
-    param: str,              # 明确的参数类型
-    optional: str = "default",  # 可选参数有默认值
-    ctx: ToolContext = None,    # 上下文参数
-) -> ToolResult:
-    """
-    详细的文档字符串
-
-    Args:
-        param: 参数说明
-        optional: 可选参数说明
-    """
-    try:
-        # 业务逻辑
-        result = do_something(param)
-
-        return ToolResult(
-            title="成功",
-            output="结果描述",
-            data={"key": "value"},  # 结构化数据
-        )
-    except Exception as e:
-        logger.error(f"错误: {e}", exc_info=True)
-        return ToolResult(
-            title="失败",
-            output=str(e),
-            error=True,
-        )
-```
-
-### 2. Skill 编写
-
-```markdown
----
-name: skill-name
-description: 简短描述
-category: 分类
----
-
-# 技能名称
-
-## 何时使用
-
-- 明确的使用场景
-- 具体的触发条件
-
-## 工作流程
-
-1. 第一步做什么
-2. 第二步做什么
-3. 如何验证结果
-
-## 最佳实践
-
-- 实用的建议
-- 常见陷阱
-```
-
-### 3. 配置管理
-
-```python
-# 使用环境变量
-MODEL = os.getenv("MODEL", "default-model")
-
-# 验证配置
-if not API_KEY:
-    raise ValueError("API_KEY 未设置")
-
-# 创建必要的目录
-Path(output_dir).mkdir(parents=True, exist_ok=True)
-```
-
-### 4. 错误处理
-
-```python
-try:
-    result = await agent.run(task)
-except Exception as e:
-    logger.error(f"执行失败: {e}", exc_info=True)
-    # 发送告警
-    # 保存错误信息
-    # 返回友好的错误消息
-```
-
-## 🔧 常用模式
-
-### 模式1:简单任务执行
-
-```python
-runner = AgentRunner(llm_call=llm, trace_store=store)
-
-async for item in runner.run(
-    messages=[{"role": "user", "content": "任务"}],
-    config=RunConfig(model="claude-sonnet-4.5"),
-):
-    if isinstance(item, Message):
-        print(item.content)
-```
-
-### 模式2:带自定义工具
-
-```python
-# 1. 定义工具
-@tool(description="我的工具")
-async def my_tool(param: str, ctx: ToolContext = None) -> ToolResult:
-    return ToolResult(output="结果")
-
-# 2. 确保工具被导入
-import my_tools  # 触发 @tool 注册
-
-# 3. 运行
-runner = AgentRunner(llm_call=llm, trace_store=store)
-async for item in runner.run(...):
-    pass
-```
-
-### 模式3:带 Skills
-
-```python
-# 1. 创建 skills/my-skill.md
-
-# 2. 加载 skills
-runner = AgentRunner(
-    llm_call=llm,
-    trace_store=store,
-    skills_dir="./skills",
-)
-
-# 3. 指定使用的 skills
-config = RunConfig(skills=["my-skill"])
-```
-
-### 模式4:生产级封装
-
-```python
-class MyAgent:
-    def __init__(self, config):
-        self.runner = AgentRunner(...)
-
-    async def run(self, task: str) -> Dict:
-        try:
-            result = await self._execute(task)
-            self._save_result(result)
-            return result
-        except Exception as e:
-            self._handle_error(e)
-            raise
-```
-
-## 📊 性能优化
-
-### 1. 选择合适的模型
-
-```python
-# 简单任务:使用 Haiku(快+便宜)
-llm = create_openrouter_llm_call(model="anthropic/claude-haiku-4.5")
-
-# 复杂任务:使用 Sonnet(平衡)
-llm = create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5")
-
-# 极难任务:使用 Opus(最强)
-llm = create_openrouter_llm_call(model="anthropic/claude-opus-4.6")
-```
-
-### 2. 限制迭代次数
-
-```python
-config = RunConfig(
-    max_iterations=20,  # 防止无限循环
-)
-```
-
-### 3. 启用 Prompt Caching
-
-```python
-config = RunConfig(
-    enable_prompt_caching=True,  # Claude 模型支持
-)
-```
-
-### 4. 限制工具权限
-
-```python
-config = RunConfig(
-    agent_type="explore",  # 只读权限,更快
-)
-```
-
-## 🐛 调试技巧
-
-### 1. 打印所有消息
-
-```python
-async for item in runner.run(...):
-    print(f"[{type(item).__name__}] {item}")
-```
-
-### 2. 查看 Trace
-
-```python
-trace = await trace_store.get_trace(trace_id)
-for msg in trace.messages:
-    print(f"{msg.role}: {msg.content}")
-```
-
-### 3. 启动可视化
-
-```bash
-python api_server.py
-# 访问 http://localhost:8000/api/traces
-```
-
-### 4. 查看日志
-
-```python
-logging.basicConfig(level=logging.DEBUG)
-```
-
-## 📦 项目模板
-
-```
-my_agent/
-├── run.py              # 主程序
-├── tools/              # 自定义工具
-│   ├── __init__.py
-│   ├── api.py
-│   └── database.py
-├── skills/             # Skills
-│   ├── main.md
-│   └── helper.md
-├── .env                # 环境变量
-├── .env.example        # 环境变量示例
-├── requirements.txt    # 依赖
-└── README.md           # 说明文档
-```
-
-## 🎓 下一步
-
-1. ✅ 阅读 [QUICKSTART.md](./QUICKSTART.md)
-2. ✅ 运行 `examples/content_finder/demo.py`
-3. ✅ 复制 `examples/production_template/`
-4. ✅ 实现第一个自定义工具
-5. ✅ 编写第一个 Skill
-6. ✅ 部署到生产环境
-
-## 💬 获取帮助
-
-- **文档**:查看 `agent/README.md` 和 `agent/docs/`
-- **示例**:参考 `examples/` 目录
-- **问题**:检查日志和 Trace
-
----
-
-**开始构建你的 Agent 吧!** 🚀

+ 0 - 562
QUICKSTART.md

@@ -1,562 +0,0 @@
-# 快速实现自己的 Agent - 生产使用指南
-
-## 🚀 三步快速开始
-
-### 第一步:安装依赖
-
-```bash
-# 安装基础依赖
-pip install -r requirements.txt
-
-# 安装 browser 模块依赖(如果需要浏览器工具)
-pip install dbutils pymysql
-
-# 配置环境变量
-cp .env.example .env
-# 编辑 .env 填入你的 API Key
-```
-
-### 第二步:创建你的 Agent
-
-创建 `my_agent/run.py`:
-
-```python
-import asyncio
-import sys
-from pathlib import Path
-
-# 添加项目根目录到 Python 路径
-sys.path.insert(0, str(Path(__file__).parent.parent))
-
-from agent import AgentRunner, RunConfig, FileSystemTraceStore
-from agent.llm import create_openrouter_llm_call
-
-async def main():
-    # 1. 初始化存储
-    trace_store = FileSystemTraceStore(base_path=".cache/traces")
-
-    # 2. 初始化 LLM
-    llm_call = create_openrouter_llm_call(
-        model="anthropic/claude-sonnet-4.5"
-    )
-
-    # 3. 创建 Runner
-    runner = AgentRunner(
-        llm_call=llm_call,
-        trace_store=trace_store,
-    )
-
-    # 4. 运行 Agent
-    async for item in runner.run(
-        messages=[{"role": "user", "content": "你的任务描述"}],
-        config=RunConfig(
-            model="anthropic/claude-sonnet-4.5",
-            max_iterations=30,
-        ),
-    ):
-        print(item)
-
-if __name__ == "__main__":
-    asyncio.run(main())
-```
-
-### 第三步:运行
-
-```bash
-python my_agent/run.py
-```
-
-## 📦 核心组件
-
-### 1. AgentRunner - 执行引擎
-
-```python
-from agent import AgentRunner, RunConfig
-
-runner = AgentRunner(
-    llm_call=llm_call,              # LLM 调用函数
-    trace_store=trace_store,        # Trace 存储
-    memory_store=memory_store,      # 记忆存储(可选)
-    skills_dir="./skills",          # Skills 目录(可选)
-)
-```
-
-### 2. LLM Providers
-
-框架支持三种 LLM 提供商:
-
-```python
-# OpenRouter(推荐,支持多种模型)
-from agent.llm import create_openrouter_llm_call
-llm_call = create_openrouter_llm_call(
-    model="anthropic/claude-sonnet-4.5",
-    api_key="your-api-key",  # 或从环境变量读取
-)
-
-# Gemini
-from agent.llm import create_gemini_llm_call
-llm_call = create_gemini_llm_call(
-    model="gemini-2.0-flash-exp",
-    api_key="your-api-key",
-)
-
-# Yescode(内部)
-from agent.llm import create_yescode_llm_call
-llm_call = create_yescode_llm_call(
-    model="gpt-4o",
-    api_key="your-api-key",
-)
-```
-
-### 3. 自定义工具
-
-使用 `@tool` 装饰器注册工具:
-
-```python
-from agent import tool, ToolResult, ToolContext
-
-@tool(description="查询产品库存")
-async def check_inventory(
-    product_id: str,
-    warehouse: str = "default",
-    ctx: ToolContext = None,
-) -> ToolResult:
-    """
-    查询指定仓库的产品库存
-
-    Args:
-        product_id: 产品唯一标识符
-        warehouse: 仓库编码,默认为主仓库
-        ctx: 工具上下文(自动注入)
-    """
-    # 你的业务逻辑
-    stock = await query_database(product_id, warehouse)
-
-    return ToolResult(
-        title="库存查询成功",
-        output=f"产品 {product_id} 在 {warehouse} 仓库的库存: {stock}",
-        data={"product_id": product_id, "stock": stock},
-    )
-```
-
-**重要**:确保定义工具的模块在 `runner.run()` 之前被 import。
-
-### 4. Skills - Agent 能力定义
-
-创建 `skills/my-skill.md`:
-
-```markdown
----
-name: my-skill
-description: 我的自定义技能
-category: custom
----
-
-# 我的技能
-
-## 何时使用
-
-- 场景1:当需要...
-- 场景2:当遇到...
-
-## 使用指南
-
-1. 首先...
-2. 然后...
-3. 最后...
-
-## 最佳实践
-
-- 建议1
-- 建议2
-```
-
-加载 Skills:
-
-```python
-runner = AgentRunner(
-    llm_call=llm_call,
-    trace_store=trace_store,
-    skills_dir="./skills",  # 指定 skills 目录
-)
-
-# 或在 RunConfig 中指定
-config = RunConfig(
-    skills=["my-skill", "planning"],  # 只加载指定的 skills
-)
-```
-
-### 5. Agent Presets - 预设配置
-
-使用内置预设:
-
-```python
-from agent import RunConfig
-
-# 默认 Agent(全部工具权限)
-config = RunConfig(agent_type="default")
-
-# 探索型 Agent(只读权限)
-config = RunConfig(agent_type="explore")
-
-# 评估型 Agent(只读+评估)
-config = RunConfig(agent_type="evaluate")
-```
-
-自定义预设:
-
-```python
-from agent import AgentPreset
-from agent.core.presets import register_preset
-
-# 定义预设
-my_preset = AgentPreset(
-    allowed_tools=["read_file", "grep_content", "my_custom_tool"],
-    denied_tools=["bash_command"],
-    max_iterations=20,
-    skills=["my-skill"],
-    description="我的自定义 Agent",
-)
-
-# 注册预设
-register_preset("my-agent", my_preset)
-
-# 使用预设
-config = RunConfig(agent_type="my-agent")
-```
-
-## 🎯 实战案例
-
-### 案例1:内容寻找 Agent
-
-参考 `examples/content_finder/`:
-
-```python
-# 1. 定义工具
-@tool(description="从抖音搜索视频")
-async def douyin_search(keywords: str, ctx: ToolContext = None) -> ToolResult:
-    results = await call_douyin_api(keywords)
-    return ToolResult(output=f"找到 {len(results)} 条内容", data=results)
-
-# 2. 定义 Skill
-# skills/content-finder.md
-
-# 3. 运行 Agent
-runner = AgentRunner(
-    llm_call=llm_call,
-    trace_store=trace_store,
-    skills_dir="./skills",
-)
-
-async for item in runner.run(
-    messages=[{"role": "user", "content": "搜索美食类视频"}],
-    config=RunConfig(skills=["content-finder"]),
-):
-    if isinstance(item, Message):
-        print(item.content)
-```
-
-### 案例2:数据分析 Agent
-
-```python
-@tool(description="查询数据库")
-async def query_db(sql: str, ctx: ToolContext = None) -> ToolResult:
-    results = await execute_sql(sql)
-    return ToolResult(
-        title="查询成功",
-        output=f"返回 {len(results)} 条记录",
-        data=results,
-    )
-
-@tool(description="生成图表")
-async def create_chart(data: list, chart_type: str, ctx: ToolContext = None) -> ToolResult:
-    chart_url = await generate_chart(data, chart_type)
-    return ToolResult(
-        title="图表已生成",
-        output=f"图表类型: {chart_type}",
-        data={"url": chart_url},
-    )
-
-# 运行
-async for item in runner.run(
-    messages=[{"role": "user", "content": "分析最近一周的销售数据并生成图表"}],
-    config=RunConfig(
-        skills=["data-analysis"],
-        max_iterations=50,
-    ),
-):
-    pass
-```
-
-### 案例3:自动化测试 Agent
-
-```python
-@tool(description="运行测试用例")
-async def run_tests(test_path: str, ctx: ToolContext = None) -> ToolResult:
-    result = await execute_tests(test_path)
-    return ToolResult(
-        title="测试完成",
-        output=f"通过: {result.passed}, 失败: {result.failed}",
-        data=result.to_dict(),
-    )
-
-@tool(description="生成测试报告")
-async def generate_report(test_results: dict, ctx: ToolContext = None) -> ToolResult:
-    report_path = await create_html_report(test_results)
-    return ToolResult(
-        title="报告已生成",
-        output=f"报告路径: {report_path}",
-    )
-```
-
-## 🔧 高级功能
-
-### 1. 子 Agent
-
-创建子 Agent 处理子任务:
-
-```python
-from agent.tools.builtin.subagent import agent
-
-# Agent 会自动调用 agent 工具创建子 Agent
-# 在 system prompt 中说明何时使用子 Agent
-```
-
-### 2. 记忆系统
-
-使用记忆存储跨会话数据:
-
-```python
-from agent.memory.stores import MemoryMemoryStore
-
-memory_store = MemoryMemoryStore()
-
-runner = AgentRunner(
-    llm_call=llm_call,
-    trace_store=trace_store,
-    memory_store=memory_store,
-)
-```
-
-### 3. Goal Tree - 计划管理
-
-Agent 自动使用 `goal` 工具管理计划:
-
-```python
-# Agent 会自动创建和更新 Goal
-# 查看 Goal Tree
-from agent.trace import FileSystemTraceStore
-
-trace_store = FileSystemTraceStore(base_path=".cache/traces")
-trace = await trace_store.get_trace(trace_id)
-goal_tree = trace.goal_tree
-```
-
-### 4. 续跑和回溯
-
-从指定消息后继续执行:
-
-```python
-# 续跑
-async for item in runner.run(
-    messages=[],
-    config=RunConfig(
-        trace_id="existing-trace-id",
-        after_sequence=10,  # 从第10条消息后继续
-    ),
-):
-    pass
-
-# 回溯重跑
-async for item in runner.run(
-    messages=[],
-    config=RunConfig(
-        trace_id="existing-trace-id",
-        after_sequence=5,  # 回到第5条消息重新执行
-    ),
-):
-    pass
-```
-
-### 5. API Server - 可视化
-
-启动 API Server 查看执行过程:
-
-```bash
-python api_server.py
-```
-
-访问:
-- `http://localhost:8000/api/traces` - 查看所有 Traces
-- `http://localhost:8000/api/traces/{id}` - 查看 Trace 详情
-- WebSocket: `ws://localhost:8000/api/traces/{id}/watch` - 实时监控
-
-## 📝 最佳实践
-
-### 1. 工具设计原则
-
-- **单一职责**:每个工具只做一件事
-- **清晰描述**:description 要准确描述工具功能
-- **详细文档**:docstring 要包含参数说明
-- **错误处理**:捕获异常并返回友好的错误信息
-
-```python
-@tool(description="发送邮件")
-async def send_email(
-    to: str,
-    subject: str,
-    body: str,
-    ctx: ToolContext = None,
-) -> ToolResult:
-    """
-    发送邮件
-
-    Args:
-        to: 收件人邮箱地址
-        subject: 邮件主题
-        body: 邮件正文
-    """
-    try:
-        await email_service.send(to, subject, body)
-        return ToolResult(
-            title="邮件发送成功",
-            output=f"已发送到 {to}",
-        )
-    except Exception as e:
-        return ToolResult(
-            title="邮件发送失败",
-            output=f"错误: {str(e)}",
-            error=str(e),
-        )
-```
-
-### 2. Skill 编写原则
-
-- **明确场景**:清楚说明何时使用这个 Skill
-- **提供指南**:给出具体的操作步骤
-- **包含示例**:展示最佳实践
-- **避免冗余**:不要重复框架已有的功能
-
-### 3. 性能优化
-
-```python
-# 1. 限制迭代次数
-config = RunConfig(max_iterations=30)
-
-# 2. 使用合适的模型
-llm_call = create_openrouter_llm_call(
-    model="anthropic/claude-haiku-4.5",  # 更快更便宜
-)
-
-# 3. 启用 Prompt Caching(Claude 模型)
-config = RunConfig(enable_prompt_caching=True)
-
-# 4. 限制工具权限
-config = RunConfig(
-    agent_type="explore",  # 只读权限,更快
-)
-```
-
-### 4. 错误处理
-
-```python
-try:
-    async for item in runner.run(messages=messages, config=config):
-        if isinstance(item, Message):
-            # 处理消息
-            pass
-except Exception as e:
-    logger.error(f"Agent 执行失败: {e}")
-    # 错误处理逻辑
-```
-
-### 5. 日志和监控
-
-```python
-import logging
-
-logging.basicConfig(
-    level=logging.INFO,
-    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
-)
-
-logger = logging.getLogger(__name__)
-
-# 在关键位置添加日志
-logger.info("开始执行 Agent")
-logger.debug(f"配置: {config}")
-```
-
-## 🚨 常见问题
-
-### Q1: 如何调试 Agent?
-
-```python
-# 1. 打印所有消息
-async for item in runner.run(messages=messages, config=config):
-    print(f"[{type(item).__name__}] {item}")
-
-# 2. 查看 Trace
-trace = await trace_store.get_trace(trace_id)
-for msg in trace.messages:
-    print(f"{msg.role}: {msg.content}")
-
-# 3. 启动 API Server 可视化
-python api_server.py
-```
-
-### Q2: 工具没有被调用?
-
-检查:
-1. 工具是否正确注册(使用 `@tool` 装饰器)
-2. 工具模块是否被 import
-3. 工具描述是否清晰
-4. Agent 是否有权限使用该工具(检查 `allowed_tools`)
-
-### Q3: Agent 陷入循环?
-
-```python
-# 1. 限制迭代次数
-config = RunConfig(max_iterations=20)
-
-# 2. 在 Skill 中明确终止条件
-# 3. 检查工具返回是否有明确的成功/失败标识
-```
-
-### Q4: 如何处理长时间运行的任务?
-
-```python
-# 使用异步处理
-import asyncio
-
-async def long_running_task():
-    async for item in runner.run(messages=messages, config=config):
-        # 定期保存状态
-        if isinstance(item, Trace):
-            await save_checkpoint(item)
-
-# 支持中断和恢复
-config = RunConfig(
-    trace_id=last_trace_id,
-    after_sequence=last_sequence,
-)
-```
-
-## 📚 参考资源
-
-- **框架文档**: `agent/README.md`
-- **架构设计**: `agent/docs/architecture.md`
-- **工具系统**: `agent/docs/tools.md`
-- **Skills 指南**: `agent/docs/skills.md`
-- **示例项目**: `examples/`
-  - `examples/content_finder/` - 内容寻找 Agent
-  - `examples/how/` - 完整示例
-  - `examples/research/` - 研究型 Agent
-
-## 🎓 下一步
-
-1. **阅读示例代码**: 从 `examples/content_finder/demo.py` 开始
-2. **创建第一个工具**: 实现一个简单的业务工具
-3. **编写 Skill**: 定义 Agent 的工作方式
-4. **测试运行**: 小规模测试验证功能
-5. **生产部署**: 添加监控、日志、错误处理

+ 116 - 67
examples/content_finder/content_finder.prompt

@@ -17,35 +17,18 @@ $system$
 - 增长方式:微信分享裂变
 - 核心指标:分享率、DAU
 
-## 工具使用说明
-
-### 1. douyin_search(抖音搜索)
-- 每次搜索默认返回约 10-20 条结果
-- 如果结果不够,使用返回的 cursor 参数继续获取下一页
-- 关键字段:
-  - aweme_id: 视频ID(用于获取画像)
-  - author.sec_uid: 作者ID(用于获取作者作品和粉丝画像)
-  - statistics.digg_count: 点赞数
-  - statistics.comment_count: 评论数
-  - statistics.share_count: 分享数
-
-### 2. douyin_user_videos(账号作品)
-- 获取指定账号的历史作品
-- 支持 cursor 分页
-- 参数 account_id 使用 author.sec_uid
-
-### 3. get_content_fans_portrait(内容点赞用户画像)
-- 参数 content_id 使用 aweme_id
-- 默认只返回年龄分布(need_age=True)
-- 如需其他维度,设置对应参数:
-  - need_gender=True: 性别分布
-  - need_province=True: 省份分布
-  - need_city_level=True: 城市等级分布
-- 偏好度(tgi)> 100 表示该人群偏好高于平均水平,= 100 表示平均水平,< 100 表示低于平均
-
-### 4. get_account_fans_portrait(账号粉丝画像)
-- 参数 account_id 使用 author.sec_uid
-- 维度设置同上
+## 核心数据使用策略
+
+### 结构化数据优先原则
+- **搜索结果**:从 `metadata.search_results` 获取数据,不要解析 output 文本
+- **账号作品**:从 `metadata.user_videos` 获取数据(格式与 search_results 一致)
+- **画像判断**:使用 `metadata.has_portrait` 字段(True=有画像,False=无画像)
+- **画像数据**:从 `metadata.portrait_data` 获取结构化数据
+
+### 关键字段说明
+- `author.sec_uid`:约80字符,必须完整复制,不能截断
+- `aweme_id`:视频ID,用于内容链接和画像查询
+- `statistics`:包含 digg_count(点赞)、comment_count(评论)、share_count(分享)
 
 ## 热度参考标准
 
@@ -55,53 +38,119 @@ $system$
 - 10000+: 高热度
 - 50000+: 爆款
 
-## 错误处理
-
-**如果遇到 HTTP 502/503/504 错误**:
-- 这是服务暂时不可用,不是参数问题
-- 不要重复尝试相同的调用(最多重试1次)
-- 如果持续失败,直接告知用户"抖音搜索服务暂时不可用,请稍后再试"
-- **不要切换到其他平台或使用其他工具**
-
-**如果遇到 HTTP 400/404 错误**:
-- 这是参数错误或资源不存在
-- 检查参数是否正确(ID格式、关键词等)
-- 调整参数后重试
-
-## 执行要求(必须遵守)
+## 工具组合策略
 
-**画像获取(必须执行)**:
-1. 对每条候选内容,先调用 get_content_fans_portrait 获取点赞用户画像
-2. 如果返回空数据或只有标题无画像内容,必须调用 get_account_fans_portrait 获取账号粉丝画像作为兜底
-3. 在结果中明确标注数据来源("内容点赞画像"或"账号粉丝画像")
+### 画像获取流程(必须执行)
+1. 对每条候选内容,先调用 `get_content_fans_portrait` 获取点赞用户画像
+2. 检查 `metadata.has_portrait` 判断是否有有效画像
+3. 如果 `has_portrait=False`,调用 `get_account_fans_portrait` 获取账号粉丝画像作为兜底
+4. 在结果中明确标注数据来源("内容点赞画像"或"账号粉丝画像")
 
-**判断画像缺失的标准**:
-- 工具返回的 output 中只有标题行和链接,没有【年龄分布】等画像数据
-- 或者 output 中包含"暂无画像数据"
-
-**优质账号扩展(必须执行)**:
+### 优质账号扩展(必须执行)
 - 如果账号粉丝画像中,目标人群占比 > 60% 且 tgi > 120
-- 必须调用 douyin_user_videos 获取该账号的 5-10 条作品
+- 调用 `douyin_user_videos` 获取该账号的 5-10 条作品
 - 对扩展作品做基础筛选(热度、相关性)
-
-**Token 管理(必须遵守)**:
-- 如果用户要求 M 条内容,只搜索 N = M × 2 条,不超过
-- 分批处理:先处理 10 条候选内容,不足再继续下一批
-- 每次最多并行调用 3 个画像工具
-- 避免一次性处理过多内容导致响应被截断
-
-**输出格式(必须包含)**:
-每条推荐内容必须包含以下链接:
+- 在输出时说明是否发现优质账号及扩展情况
+
+### Token 管理(必须遵守)
+- **搜索控制**:用户要求 M 条内容,只搜索 N = M × 2 条,搜索到后立即停止
+- **分批处理**:先处理 10 条候选内容,不足再继续下一批
+- **工具调用限制**:每次最多并行调用 3 个画像工具
+- **画像获取完成标准**:获取画像后立即进入筛选阶段,不要继续搜索新内容
+
+## 数据真实性要求(严格遵守)
+
+**禁止编造数据**:这是最严重的错误,会导致 404 错误和用户体验问题。
+
+### 唯一数据源
+- 推荐结果的所有数据必须来自 `metadata.search_results` 或 `metadata.user_videos`
+- **禁止**从 output 文本中解析数据(可能有格式问题)
+- **禁止**编造任何不在 metadata 中的数据
+- **禁止**拼接、修改、截断任何字段值
+
+### 字段完整性
+- `author.sec_uid` 必须**逐字符完整复制**(约80字符),不能截断、不能修改
+- 格式检查:必须以 `MS4wLjABAAAA` 开头,后面约 68 个字符
+- `aweme_id`、作者名、热度数据必须来自**同一条记录**,不能混用
+
+### 数据获取步骤(必须遵守)
+1. 从工具返回的 `metadata.search_results` 或 `metadata.user_videos` 中选择一条记录
+2. 从该记录中提取 `author.sec_uid`,**完整复制**,不做任何修改
+3. 使用该 sec_uid 调用画像工具
+4. 如果 metadata 中没有该字段或为空,**不要编造**,标注为"无数据"
+
+### 错误示例(禁止)
+❌ 编造 sec_uid:`MS4wLjABAAAA2Ue8Ks9rkqNmLCy_3bRYCcjmLPXCxQzQOWrGGLZqLmNjFCFUhXJWVLPOxLPO`
+❌ 截断 sec_uid:`MS4wLjABAAAAknWSpc8MaIgiXwRsohQtmeF6dJD0CxofXq4v8QtSVDw5eyehGrb_P4a`
+❌ 从 output 解析:从文本 "sec_uid: MS4w..." 中提取
+❌ 混用字段:用 A 记录的 aweme_id + B 记录的 sec_uid
+
+### 正确示例
+✅ 从 metadata.search_results[0] 中获取:
+```python
+item = metadata.search_results[0]
+aweme_id = item["aweme_id"]  # "7598168772859838016"
+sec_uid = item["author"]["sec_uid"]  # 完整复制,约80字符
+```
+
+### 示例
+如果 metadata.search_results[0] = {
+  "aweme_id": "7598168772859838016",
+  "desc": "养老服务消费补贴全国落地",
+  "author": {
+    "nickname": "宁波养老小翁",
+    "sec_uid": "MS4wLjABAAAAknWSpc8MaIgiXwRsohQtmeF6dJD0CxofXq4v8QtSVDw5eyehGrb_P4aMQisRyUuY"
+  },
+  "statistics": {"digg_count": 343, "comment_count": 33, "share_count": 369}
+}
+
+则输出:
+- 内容链接:https://www.douyin.com/video/7598168772859838016
+- 作者:宁波养老小翁
+- 作者链接:https://www.douyin.com/user/MS4wLjABAAAAknWSpc8MaIgiXwRsohQtmeF6dJD0CxofXq4v8QtSVDw5eyehGrb_P4aMQisRyUuY
+- 热度:👍 343 | 💬 33 | 🔄 369
+
+**违反后果**:编造数据会导致404错误,严重影响用户体验。
+
+## 输出格式要求
+
+每条推荐内容必须包含:
+- 内容标题(来自 metadata)
 - 内容链接:https://www.douyin.com/video/{aweme_id}
-- 作者链接:https://www.douyin.com/user/{author.sec_uid}
-- 画像链接(如果获取了画像数据):
-  - 内容点赞画像:https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}
-  - 账号粉丝画像:https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author.sec_uid}
+- 作者名称和链接:https://www.douyin.com/user/{author.sec_uid}(完整复制)
+- 热度数据:点赞、评论、分享(来自 metadata.statistics)
+- 画像数据(如果 has_portrait=True):
+  - 50岁以上占比和 tgi 值
+  - 画像链接:
+    - 内容点赞画像:https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}
+    - 账号粉丝画像:https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author.sec_uid}
+  - 数据来源标注:”内容点赞画像”或”账号粉丝画像”
+- 如果没有画像数据,明确标注:”无画像数据”
+
+推荐结果开头必须说明:
+1. 搜索情况:搜索到多少条候选内容
+2. 画像获取情况:成功获取内容点赞画像的数量、账号粉丝画像的数量、无画像数据的数量
+3. 筛选情况:多少条符合老年人画像要求,最终推荐多少条
+4. 优质账号扩展情况:是否发现优质账号及扩展结果
+
+## 任务完成要求
+
+- 搜索 M × 2 条内容后,立即停止搜索
+- 对所有搜索到的内容获取画像后,立即进入筛选阶段
+- 筛选完成后,立即输出完整的推荐结果
+- 输出完整的推荐结果后,任务会自动进行反思和知识保存
+- 反思完成后,输出简短的完成确认:✅ 任务完成!已为您找到 [数量] 条视频,并保存了执行经验
 
 请按照 content_finding_strategy_v2 和 content_filtering_strategy_v2 中的方法论执行任务。
 
+**关键提醒**:
+- 不要陷入”一直获取画像”的循环
+- 获取足够画像后,立即进入筛选和输出阶段
+- 必须输出最终推荐结果,不能在中途停止
+- 所有数据必须来自 metadata,禁止编造
+
 $user$
-找10个和“伊朗局势”相关的,老年人感兴趣的视频。
+找10个和”养老服务与政策扶持”相关的,老年人感兴趣的视频。
 
 要求:
 - 适合老年人分享观看

+ 211 - 5
examples/content_finder/run.py

@@ -49,6 +49,185 @@ logging.basicConfig(
 logger = logging.getLogger(__name__)
 
 
+async def generate_fallback_output(store: FileSystemTraceStore, trace_id: str):
+    """
+    当任务未正常输出时,从 trace 中提取数据并生成兜底输出
+    """
+    try:
+        # 读取所有消息
+        messages_dir = Path(store.base_path) / trace_id / "messages"
+        if not messages_dir.exists():
+            print("无法生成摘要:找不到消息目录")
+            return
+
+        # 提取搜索结果和画像数据
+        search_results = []
+        portrait_data = {}
+
+        import json
+        import re
+
+        for msg_file in sorted(messages_dir.glob("*.json")):
+            with open(msg_file, 'r', encoding='utf-8') as f:
+                msg = json.load(f)
+
+            # 提取搜索结果(从文本结果中解析)
+            if msg.get("role") == "tool" and msg.get("content", {}).get("tool_name") == "douyin_search":
+                result_text = msg.get("content", {}).get("result", "")
+
+                # 解析每条搜索结果
+                lines = result_text.split("\n")
+                current_item = {}
+
+                for line in lines:
+                    line = line.strip()
+                    if not line:
+                        if current_item.get("aweme_id"):
+                            if current_item["aweme_id"] not in [r["aweme_id"] for r in search_results]:
+                                search_results.append(current_item)
+                            current_item = {}
+                        continue
+
+                    # 解析标题行(以数字开头)
+                    if re.match(r'^\d+\.', line):
+                        current_item["desc"] = line.split(".", 1)[1].strip()[:100]
+                    # 解析 ID
+                    elif line.startswith("ID:"):
+                        current_item["aweme_id"] = line.split("ID:")[1].strip()
+                    # 解析作者
+                    elif line.startswith("作者:"):
+                        author_name = line.split("作者:")[1].strip()
+                        current_item["author"] = {"nickname": author_name}
+                    # 解析 sec_uid
+                    elif line.startswith("sec_uid:"):
+                        sec_uid = line.split("sec_uid:")[1].strip()
+                        if "author" not in current_item:
+                            current_item["author"] = {}
+                        current_item["author"]["sec_uid"] = sec_uid
+                    # 解析数据
+                    elif line.startswith("数据:"):
+                        stats_text = line.split("数据:")[1].strip()
+                        stats = {}
+                        # 解析点赞数
+                        if "点赞" in stats_text:
+                            digg_match = re.search(r'点赞\s+([\d,]+)', stats_text)
+                            if digg_match:
+                                stats["digg_count"] = int(digg_match.group(1).replace(",", ""))
+                        # 解析评论数
+                        if "评论" in stats_text:
+                            comment_match = re.search(r'评论\s+([\d,]+)', stats_text)
+                            if comment_match:
+                                stats["comment_count"] = int(comment_match.group(1).replace(",", ""))
+                        # 解析分享数
+                        if "分享" in stats_text:
+                            share_match = re.search(r'分享\s+([\d,]+)', stats_text)
+                            if share_match:
+                                stats["share_count"] = int(share_match.group(1).replace(",", ""))
+                        current_item["statistics"] = stats
+
+                # 添加最后一条
+                if current_item.get("aweme_id"):
+                    if current_item["aweme_id"] not in [r["aweme_id"] for r in search_results]:
+                        search_results.append(current_item)
+
+            # 提取画像数据
+            elif msg.get("role") == "tool":
+                tool_name = msg.get("content", {}).get("tool_name", "")
+                result_text = msg.get("content", {}).get("result", "")
+
+                if tool_name in ["get_content_fans_portrait", "get_account_fans_portrait"]:
+                    # 解析画像数据
+                    content_id = None
+                    age_50_plus = None
+                    tgi = None
+
+                    # 从结果文本中提取 ID
+                    if "内容 " in result_text:
+                        parts = result_text.split("内容 ")[1].split(" ")[0]
+                        content_id = parts
+                    elif "账号 " in result_text:
+                        parts = result_text.split("账号 ")[1].split(" ")[0]
+                        content_id = parts
+
+                    # 提取50岁以上数据(格式:50-: 48.35% (偏好度: 210.05))
+                    if "【年龄】分布" in result_text:
+                        lines = result_text.split("\n")
+                        for line in lines:
+                            if "50-:" in line:
+                                # 解析:  50-: 48.35% (偏好度: 210.05)
+                                parts = line.split("50-:")[1].strip()
+                                if "%" in parts:
+                                    age_50_plus = parts.split("%")[0].strip()
+                                if "偏好度:" in parts:
+                                    tgi_part = parts.split("偏好度:")[1].strip()
+                                    tgi = tgi_part.replace(")", "").strip()
+                                break
+
+                    if content_id and age_50_plus:
+                        portrait_data[content_id] = {
+                            "age_50_plus": age_50_plus,
+                            "tgi": tgi,
+                            "source": "内容点赞画像" if tool_name == "get_content_fans_portrait" else "账号粉丝画像"
+                        }
+
+        # 生成输出
+        print("\n" + "="*60)
+        print("📊 任务执行摘要(兜底输出)")
+        print("="*60)
+        print(f"\n搜索情况:找到 {len(search_results)} 条候选内容")
+        print(f"画像获取:获取了 {len(portrait_data)} 条画像数据")
+
+        # 筛选有画像且符合要求的内容
+        matched_results = []
+        for result in search_results:
+            aweme_id = result["aweme_id"]
+            author_id = result["author"].get("sec_uid", "")
+
+            # 查找画像数据(优先内容画像,其次账号画像)
+            portrait = portrait_data.get(aweme_id) or portrait_data.get(author_id)
+
+            if portrait and portrait.get("age_50_plus"):
+                try:
+                    age_ratio = float(portrait["age_50_plus"])
+                    if age_ratio >= 20:  # 50岁以上占比>=20%
+                        matched_results.append({
+                            **result,
+                            "portrait": portrait
+                        })
+                except:
+                    pass
+
+        # 按50岁以上占比排序
+        matched_results.sort(key=lambda x: float(x["portrait"]["age_50_plus"]), reverse=True)
+
+        # 输出推荐结果
+        print(f"\n符合要求:{len(matched_results)} 条内容(50岁以上占比>=20%)")
+        print("\n" + "="*60)
+        print("🎯 推荐结果")
+        print("="*60)
+
+        for i, result in enumerate(matched_results[:10], 1):
+            aweme_id = result["aweme_id"]
+            desc = result["desc"]
+            author = result["author"]
+            stats = result["statistics"]
+            portrait = result["portrait"]
+
+            print(f"\n{i}. {desc}")
+            print(f"   链接: https://www.douyin.com/video/{aweme_id}")
+            print(f"   作者: {author.get('nickname', '未知')}")
+            print(f"   热度: 👍 {stats.get('digg_count', 0):,} | 💬 {stats.get('comment_count', 0):,} | 🔄 {stats.get('share_count', 0):,}")
+            print(f"   画像: 50岁以上 {portrait['age_50_plus']}% (tgi: {portrait['tgi']}) - {portrait['source']}")
+
+        print("\n" + "="*60)
+        print(f"✅ 已为您找到 {min(len(matched_results), 10)} 条推荐视频")
+        print("="*60)
+
+    except Exception as e:
+        logger.error(f"生成兜底输出失败: {e}", exc_info=True)
+        print(f"\n生成摘要失败: {e}")
+
+
 async def main():
     print("\n" + "=" * 60)
     print("内容寻找 Agent")
@@ -100,11 +279,22 @@ async def main():
     )
 
     # 执行
+    trace_id = None
+    has_final_output = False
+
     try:
         async for item in runner.run(messages=messages, config=config):
             if isinstance(item, Trace):
+                trace_id = item.trace_id
+
                 if item.status == "completed":
                     print(f"\n[完成] trace_id={item.trace_id}")
+
+                    # 检查是否有最终输出
+                    if not has_final_output:
+                        print("\n⚠️ 检测到任务未完整输出,正在生成摘要...")
+                        await generate_fallback_output(store, item.trace_id)
+
                 elif item.status == "failed":
                     print(f"\n[失败] {item.error_message}")
 
@@ -114,14 +304,30 @@ async def main():
                     if isinstance(content, dict):
                         text = content.get("text", "")
                         tool_calls = content.get("tool_calls")
-                        if text and not tool_calls:
-                            print(f"\n{text}")
-                        elif text:
-                            print(f"[思考] {text[:100]}..." if len(text) > 100 else f"[思考] {text}")
+
+                        # 输出文本内容
+                        if text:
+                            # 检测是否包含最终推荐结果
+                            if "推荐结果" in text or "推荐内容" in text or "🎯" in text:
+                                has_final_output = True
+
+                            # 如果文本很长(>500字符)且包含推荐结果标记,输出完整内容
+                            if len(text) > 500 and ("推荐结果" in text or "推荐内容" in text or "🎯" in text):
+                                print(f"\n{text}")
+                            # 如果有工具调用且文本较短,只输出摘要
+                            elif tool_calls and len(text) > 100:
+                                print(f"[思考] {text[:100]}...")
+                            # 其他情况输出完整文本
+                            else:
+                                print(f"\n{text}")
+
+                        # 输出工具调用信息
                         if tool_calls:
                             for tc in tool_calls:
                                 tool_name = tc.get("function", {}).get("name", "unknown")
-                                print(f"[工具] {tool_name}")
+                                # 跳过 goal 工具的输出,减少噪音
+                                if tool_name != "goal":
+                                    print(f"[工具] {tool_name}")
                     elif isinstance(content, str) and content:
                         print(f"\n{content}")
 

+ 15 - 5
examples/content_finder/skills/content_filtering_strategy_v2.md

@@ -48,19 +48,23 @@
 
 **优先级1:内容点赞用户画像**
 - 调用 `get_content_fans_portrait(content_id=aweme_id)`
-- 如果返回有效画像数据:
+- 检查返回的 metadata.has_portrait 字段
+- 如果 has_portrait 为 True:
+  - 从 metadata.portrait_data 中获取结构化画像数据
   - 评估是否符合目标人群
   - 在结果中标注"数据来源:内容点赞画像"
 
 **优先级2:账号粉丝画像(兜底)**
-- 如果点赞画像数据缺失(返回只有标题无画像内容,或包含"暂无画像数据"
+- 如果 metadata.has_portrait 为 False(画像数据缺失
 - 调用 `get_account_fans_portrait(account_id=author.sec_uid)`
-- 如果返回有效画像数据:
+- 检查返回的 metadata.has_portrait 字段
+- 如果 has_portrait 为 True:
+  - 从 metadata.portrait_data 中获取结构化画像数据
   - 评估是否符合目标人群
   - 在结果中标注"数据来源:账号粉丝画像(内容点赞画像缺失)"
 
 **优先级3:无画像数据**
-- 如果两种画像都无法获取
+- 如果两种画像的 has_portrait 都为 False
 - 仅基于热度和相关性评估
 - 在结果中标注"数据来源:无画像数据"
 
@@ -107,6 +111,7 @@
 **获取账号作品**:
 - 调用 `douyin_user_videos(account_id=author.sec_uid)`
 - 限制数量:5-10 条近期作品
+- 从返回的 metadata.user_videos 中获取结构化数据
 
 **筛选扩展作品**:
 - **仅执行阶段一筛选**(热度、相关性)
@@ -170,14 +175,19 @@
 
 **每条内容包含**:
 - 内容基本信息(ID、描述、作者)
+  - **数据来源**:必须从 metadata.search_results 或 metadata.user_videos 中获取
+  - **sec_uid 要求**:必须完整复制(约80字符),不能截断
 - 热度数据(点赞、评论、分享)
+  - **数据来源**:必须从 metadata 中的 statistics 字段获取
 - 画像数据(目标人群占比、tgi)
+  - **数据来源**:从 metadata.portrait_data 中获取
+  - **有效性判断**:通过 metadata.has_portrait 字段判断
 - 数据来源标注
 - 推荐理由
 - **必须包含链接**:
   - 内容链接:https://www.douyin.com/video/{aweme_id}
   - 作者链接:https://www.douyin.com/user/{author.sec_uid}
-  - 画像链接(如果):
+  - 画像链接(如果 has_portrait 为 True):
     - 内容点赞画像:https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}
     - 账号粉丝画像:https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author.sec_uid}
 

+ 37 - 8
examples/content_finder/skills/content_finding_strategy_v2.md

@@ -65,22 +65,35 @@
 - 如果不足,继续处理下一批 10 条
 - 目的:避免一次性调用过多工具导致 token 超限
 
+**画像获取完成标准(重要)**:
+- 当已获取画像的内容数量 >= M × 1.5 时,立即停止获取画像
+- 示例:用户要10条,获取15条画像后立即进入筛选和输出阶段
+- 不要无限循环获取画像,避免陷入"一直获取画像"的状态
+
 **工具调用限制**:
 - 每次最多并行调用 3 个画像工具
 - 避免一次性调用过多工具导致响应被截断
 
 **对每条候选内容**:
 
-1. **优先获取内容点赞用户画像**:
+1. **从 metadata.search_results 或 metadata.user_videos 中获取基础信息**:
+   - aweme_id、desc、author.nickname、author.sec_uid、statistics
+   - 这些数据将用于最终输出,必须完整保留
+   - 特别注意:author.sec_uid 约80字符,必须完整复制
+
+2. **优先获取内容点赞用户画像**:
    - 调用 `get_content_fans_portrait(content_id=aweme_id)`
-   - 如果画像数据存在:评估是否符合目标人群,标注"内容点赞画像"
+   - 检查返回的 metadata.has_portrait 字段
+   - 如果 has_portrait 为 True:评估是否符合目标人群,标注"内容点赞画像"
 
-2. **画像缺失时的兜底策略**:
-   - 如果点赞画像数据缺失(返回空数据)
+3. **画像缺失时的兜底策略**:
+   - 如果 metadata.has_portrait 为 False(无画像数据)
    - 获取该内容作者的账号粉丝画像:`get_account_fans_portrait(account_id=author.sec_uid)`
-   - 评估是否符合目标人群,标注"账号粉丝画像"
+   - 检查账号画像的 metadata.has_portrait
+   - 如果有画像:评估是否符合目标人群,标注"账号粉丝画像"
 
-3. **画像评估**:
+4. **画像评估**:
+   - 从 metadata.portrait_data 中获取结构化的画像数据
    - 根据目标人群的占比和偏好度(tgi)判断
    - 偏好度 > 100 表示该人群偏好高于平均水平,= 100 表示平均,< 100 表示低于平均
    - 筛选出符合要求的内容
@@ -94,10 +107,17 @@
 **扩展策略**:
 - 对优质账号,获取其近期作品:`douyin_user_videos(account_id=author.sec_uid)`
 - 限制数量:5-10 条
+- 从返回的 metadata.user_videos 中获取结构化数据
 - 对这些作品**仅执行阶段一筛选**(热度、相关性)
 - **不再递归获取画像**,避免无限展开
 - 作为补充内容加入候选池
 
+**必须说明(重要)**:
+- 在输出推荐结果时,必须明确说明优质账号扩展情况
+- 如果发现优质账号:说明"发现 X 个优质账号(账号名,目标人群占比 Y%,tgi Z),已扩展其作品"
+- 如果未发现优质账号:说明"未发现符合扩展条件的优质账号(需要目标人群占比 > 60% 且 tgi > 120)"
+- 让用户清楚知道是否执行了扩展,以及扩展的结果
+
 ---
 
 ### 第四步:结果评估与补充
@@ -125,9 +145,13 @@
 **输出结果**:
 - 按分层输出:强烈推荐、推荐、可选
 - 说明每条内容的推荐理由和数据来源
+- **数据来源要求**:
+  - 所有基础信息(aweme_id、作者名、sec_uid、热度数据)必须来自 metadata.search_results
+  - 不能使用 output 文本中的数据
+  - 不能编造任何字段
 - **必须包含链接**:
   - 内容链接:https://www.douyin.com/video/{aweme_id}
-  - 作者链接:https://www.douyin.com/user/{author.sec_uid}
+  - 作者链接:https://www.douyin.com/user/{author.sec_uid}(完整复制,不截断)
   - 画像链接(如果有):
     - 内容点赞画像:https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}
     - 账号粉丝画像:https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author.sec_uid}
@@ -138,6 +162,7 @@
 
 **服务级错误(HTTP 502/503/504)**:
 - 这是服务暂时不可用,不是参数问题
+- 工具会返回详细的错误信息,包含 HTTP 状态码
 - 不要重复尝试相同的调用(最多重试1次)
 - 直接告知用户"服务暂时不可用,请稍后再试"
 - 不要切换到其他平台或工具
@@ -146,7 +171,11 @@
 - 检查参数格式是否正确
 - 调整参数后重试
 
-**网络错误(Timeout/Connection)**:
+**超时错误(Timeout)**:
+- 工具会返回明确的超时错误信息
+- 可以重试1次,如果仍然超时则告知用户
+
+**网络错误(Connection/Network)**:
 - 可以重试1-2次
 - 如果持续失败,告知用户网络问题
 

+ 89 - 12
examples/content_finder/tools/douyin_search.py

@@ -4,14 +4,16 @@
 调用内部爬虫服务进行抖音关键词搜索。
 """
 import asyncio
-import json
+import logging
+import time
 from typing import Optional
 
-import httpx
 import requests
 
 from agent.tools import tool, ToolResult
 
+logger = logging.getLogger(__name__)
+
 
 # API 基础配置
 DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
@@ -31,18 +33,40 @@ async def douyin_search(
     """
     抖音关键词搜索
 
+    通过关键词搜索抖音平台的视频内容,支持多种排序和筛选方式。
+
     Args:
         keyword: 搜索关键词
         content_type: 内容类型(可选:视频/图文, 默认 "视频")
         sort_type: 排序方式(可选:综合排序/最新发布/最多点赞,默认 "综合排序")
         publish_time: 发布时间范围(可选:不限/一天内/一周内/半年内,默认 "不限")
-        cursor: 分页游标(默认 "0")
+        cursor: 分页游标,用于获取下一页结果,默认 "0"
         account_id: 账号ID(可选)
         timeout: 超时时间(秒),默认 60
 
     Returns:
-        ToolResult: 搜索结果 JSON
+        ToolResult: 包含以下内容:
+            - output: 文本格式的搜索结果摘要
+            - metadata.search_results: 结构化的搜索结果列表
+                - aweme_id: 视频ID
+                - desc: 视频描述(最多100字符)
+                - author: 作者信息
+                    - nickname: 作者昵称
+                    - sec_uid: 作者ID(完整,约80字符)
+                - statistics: 统计数据
+                    - digg_count: 点赞数
+                    - comment_count: 评论数
+                    - share_count: 分享数
+            - metadata.raw_data: 原始 API 返回数据
+
+    Note:
+        - 使用 cursor 参数可以获取下一页结果
+        - 建议从 metadata.search_results 获取结构化数据,而非解析 output 文本
+        - author.sec_uid 约 80 字符,使用时不要截断
+        - 返回的 cursor 值可用于下一次搜索的 cursor 参数
     """
+    start_time = time.time()
+
     try:
         payload = {
             "keyword": keyword,
@@ -91,35 +115,88 @@ async def douyin_search(
             summary_lines.append(f"{i}. {desc}")
             summary_lines.append(f"   ID: {aweme_id}")
             summary_lines.append(f"   链接: https://www.douyin.com/video/{aweme_id}")
-            summary_lines.append(f"   作者: {author_name} (sec_uid: {author_id})")
+            summary_lines.append(f"   作者: {author_name}")
+            summary_lines.append(f"   sec_uid: {author_id}")
             summary_lines.append(f"   数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
             summary_lines.append("")
 
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "douyin_search completed",
+            extra={
+                "keyword": keyword,
+                "results_count": len(items),
+                "has_more": has_more,
+                "cursor": cursor_value,
+                "duration_ms": duration_ms
+            }
+        )
+
         return ToolResult(
             title=f"抖音搜索: {keyword}",
             output="\n".join(summary_lines),
             long_term_memory=f"Searched Douyin for '{keyword}', found {len(items)} results",
-            metadata={"raw_data": data}
+            metadata={
+                "raw_data": data,
+                "search_results": [  # 结构化搜索结果,供 Agent 直接引用
+                    {
+                        "aweme_id": item.get("aweme_id"),
+                        "desc": (item.get("desc") or item.get("item_title") or "无标题")[:100],
+                        "author": {
+                            "nickname": item.get("author", {}).get("nickname", "未知作者"),
+                            "sec_uid": item.get("author", {}).get("sec_uid", ""),
+                        },
+                        "statistics": {
+                            "digg_count": item.get("statistics", {}).get("digg_count", 0),
+                            "comment_count": item.get("statistics", {}).get("comment_count", 0),
+                            "share_count": item.get("statistics", {}).get("share_count", 0),
+                        }
+                    }
+                    for item in items
+                ]
+            }
+        )
+    except requests.exceptions.HTTPError as e:
+        logger.error(
+            "douyin_search HTTP error",
+            extra={
+                "keyword": keyword,
+                "status_code": e.response.status_code,
+                "error": str(e)
+            }
+        )
+        return ToolResult(
+            title="抖音搜索失败",
+            output="",
+            error=f"HTTP {e.response.status_code}: {e.response.text}"
+        )
+    except requests.exceptions.Timeout:
+        logger.error("douyin_search timeout", extra={"keyword": keyword, "timeout": request_timeout})
+        return ToolResult(
+            title="抖音搜索失败",
+            output="",
+            error=f"请求超时({request_timeout}秒)"
         )
-    except httpx.HTTPStatusError as e:
+    except requests.exceptions.RequestException as e:
+        logger.error("douyin_search network error", extra={"keyword": keyword, "error": str(e)})
         return ToolResult(
             title="抖音搜索失败",
             output="",
-            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+            error=f"网络错误: {str(e)}"
         )
     except Exception as e:
+        logger.error("douyin_search unexpected error", extra={"keyword": keyword, "error": str(e)}, exc_info=True)
         return ToolResult(
             title="抖音搜索失败",
             output="",
-            error=str(e)
+            error=f"未知错误: {str(e)}"
         )
 
 
-#
 async def main():
     result = await douyin_search(
-        keyword="宁艺卓",
-        account_id = "771431186"
+        keyword="养老政策",
+        account_id="771431186"
     )
     print(result.output)
 

+ 92 - 22
examples/content_finder/tools/douyin_user_videos.py

@@ -3,15 +3,17 @@
 
 调用内部爬虫服务获取指定账号的历史作品列表。
 """
-import json
+import asyncio
+import logging
+import time
 from typing import Optional
 
-import asyncio
-import httpx
 import requests
 
 from agent.tools import tool, ToolResult
 
+logger = logging.getLogger(__name__)
+
 
 # API 基础配置
 DOUYIN_BLOGGER_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/blogger"
@@ -28,15 +30,37 @@ async def douyin_user_videos(
     """
     抖音账号历史作品查询
 
+    获取指定抖音账号的历史作品列表,支持排序和分页。
+
     Args:
-        account_id: 抖音账号ID(account_id)
+        account_id: 抖音账号ID(使用 author.sec_uid)
         sort_type: 排序方式(可选:最新/最热,默认 "最新")
-        cursor: 分页游标(默认 "")
+        cursor: 分页游标,用于获取下一页结果,默认 ""
         timeout: 超时时间(秒),默认 60
 
     Returns:
-        ToolResult: 作品列表 JSON
+        ToolResult: 包含以下内容:
+            - output: 文本格式的作品列表摘要(显示前5条)
+            - metadata.user_videos: 结构化的作品列表(与 search_results 格式一致)
+                - aweme_id: 视频ID
+                - desc: 视频描述(最多100字符)
+                - author: 作者信息
+                    - nickname: 作者昵称
+                    - sec_uid: 作者ID(完整,约80字符)
+                - statistics: 统计数据
+                    - digg_count: 点赞数
+                    - comment_count: 评论数
+                    - share_count: 分享数
+            - metadata.raw_data: 原始 API 返回数据
+
+    Note:
+        - account_id 参数使用 author.sec_uid(约80字符)
+        - 使用 cursor 参数可以获取下一页结果
+        - 建议从 metadata.user_videos 获取结构化数据
+        - user_videos 与 search_results 格式完全一致,可使用相同的处理逻辑
     """
+    start_time = time.time()
+
     try:
         payload = {
             "account_id": account_id,
@@ -46,15 +70,6 @@ async def douyin_user_videos(
 
         request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
 
-        # async with httpx.AsyncClient(timeout=request_timeout) as client:
-        #     response = await client.post(
-        #         DOUYIN_BLOGGER_API,
-        #         json=payload,
-        #         headers={"Content-Type": "application/json"},
-        #     )
-        #     response.raise_for_status()
-        #     data = response.json()
-
         response = requests.post(
             DOUYIN_BLOGGER_API,
             json=payload,
@@ -92,31 +107,86 @@ async def douyin_user_videos(
             summary_lines.append(f"{i}. {desc}")
             summary_lines.append(f"   ID: {aweme_id}")
             summary_lines.append(f"   链接: https://www.douyin.com/video/{aweme_id}")
-            summary_lines.append(f"   作者: {author_name} (sec_uid: {author_id})")
+            summary_lines.append(f"   作者: {author_name}")
+            summary_lines.append(f"   sec_uid: {author_id}")
             summary_lines.append(f"   数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
             summary_lines.append("")
 
         if len(items) > 5:
             summary_lines.append(f"... 还有 {len(items) - 5} 条结果")
 
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "douyin_user_videos completed",
+            extra={
+                "account_id": account_id,
+                "results_count": len(items),
+                "has_more": has_more,
+                "cursor": cursor_value,
+                "duration_ms": duration_ms
+            }
+        )
+
         return ToolResult(
             title=f"账号作品: {account_id}",
             output="\n".join(summary_lines),
             long_term_memory=f"Fetched {len(items)} videos for account '{account_id}'",
-            metadata={"raw_data": data}
+            metadata={
+                "raw_data": data,
+                "user_videos": [  # 结构化数据,与 search_results 保持一致
+                    {
+                        "aweme_id": item.get("aweme_id"),
+                        "desc": (item.get("desc") or item.get("item_title") or "无标题")[:100],
+                        "author": {
+                            "nickname": item.get("author", {}).get("nickname", "未知作者"),
+                            "sec_uid": item.get("author", {}).get("sec_uid", ""),
+                        },
+                        "statistics": {
+                            "digg_count": item.get("statistics", {}).get("digg_count", 0),
+                            "comment_count": item.get("statistics", {}).get("comment_count", 0),
+                            "share_count": item.get("statistics", {}).get("share_count", 0),
+                        }
+                    }
+                    for item in items
+                ]
+            }
         )
-    except httpx.HTTPStatusError as e:
+    except requests.exceptions.HTTPError as e:
+        logger.error(
+            "douyin_user_videos HTTP error",
+            extra={
+                "account_id": account_id,
+                "status_code": e.response.status_code,
+                "error": str(e)
+            }
+        )
+        return ToolResult(
+            title="账号作品获取失败",
+            output="",
+            error=f"HTTP {e.response.status_code}: {e.response.text}",
+        )
+    except requests.exceptions.Timeout:
+        logger.error("douyin_user_videos timeout", extra={"account_id": account_id, "timeout": request_timeout})
         return ToolResult(
             title="账号作品获取失败",
-            output="请求异常",
-            error=f"HTTP error {e.response.status_code}: {e.response.text}",
+            output="",
+            error=f"请求超时({request_timeout}秒)",
+        )
+    except requests.exceptions.RequestException as e:
+        logger.error("douyin_user_videos network error", extra={"account_id": account_id, "error": str(e)})
+        return ToolResult(
+            title="账号作品获取失败",
+            output="",
+            error=f"网络错误: {str(e)}",
         )
     except Exception as e:
+        logger.error("douyin_user_videos unexpected error", extra={"account_id": account_id, "error": str(e)}, exc_info=True)
         return ToolResult(
             title="账号作品获取失败",
-            output="账号作品获取失败",
-            error=str(e),
+            output="",
+            error=f"未知错误: {str(e)}",
         )
+
 async def main():
     result = await douyin_user_videos(
         account_id="MS4wLjABAAAAPRCMGPAFM1VGcJrxRuvTXgJp0Sk95EW1DynNmbKSPg8",

+ 195 - 38
examples/content_finder/tools/hotspot_profile.py

@@ -4,14 +4,16 @@
 调用内部爬虫服务获取账号/内容的粉丝画像。
 """
 import asyncio
-import json
+import logging
+import time
 from typing import Optional, Dict, Any, List, Tuple
 
-import httpx
 import requests
 
 from agent.tools import tool, ToolResult
 
+logger = logging.getLogger(__name__)
+
 
 ACCOUNT_FANS_PORTRAIT_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
 CONTENT_FANS_PORTRAIT_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait"
@@ -33,6 +35,8 @@ async def get_account_fans_portrait(
     """
     获取抖音账号粉丝画像(热点宝数据)
 
+    获取指定账号的粉丝画像数据,包括年龄、性别、地域等多个维度。
+
     Args:
         account_id: 抖音账号ID(使用 author.sec_uid)
         need_province: 是否获取省份分布,默认 False
@@ -45,15 +49,56 @@ async def get_account_fans_portrait(
         timeout: 超时时间(秒),默认 60
 
     Returns:
-        ToolResult: 包含粉丝画像数据
-        - ratio: 占比(百分比)
-        - tgi (偏好度): > 100 表示该人群偏好高于平均水平,< 100 表示低于平均,= 100 表示平均水平
-          例如:50岁以上 tgi=150 表示该账号粉丝中50岁以上人群的偏好度是平台平均的1.5倍
-
-    注意:
+        ToolResult: 包含以下内容:
+            - output: 文本格式的画像摘要
+            - metadata.has_portrait: 布尔值,表示是否有有效画像数据
+                - True: 有有效画像数据
+                - False: 无画像数据
+            - metadata.portrait_data: 结构化的画像数据(字典格式)
+                - 键: 维度名称(如 "年龄"、"性别")
+                - 值: 该维度的分布数据(字典)
+                    - percentage: 占比(如 "48.35%")
+                    - preference: 偏好度/TGI(如 "210.05")
+            - metadata.raw_data: 原始 API 返回数据
+
+    Note:
+        - account_id 参数使用 author.sec_uid(约80字符)
         - 默认只返回年龄分布,需要其他维度时设置对应参数为 True
         - 省份数据只显示 TOP5
+        - 偏好度(TGI)说明:
+            - > 100: 该人群偏好高于平均水平
+            - = 100: 平均水平
+            - < 100: 低于平均水平
+        - 使用 metadata.has_portrait 判断画像是否有效,不要解析 output 文本
+        - 从 metadata.portrait_data 获取结构化画像数据
     """
+    start_time = time.time()
+
+    # 验证 account_id 格式
+    if not account_id or not isinstance(account_id, str):
+        logger.error("get_account_fans_portrait invalid account_id", extra={"account_id": account_id})
+        return ToolResult(
+            title="账号粉丝画像获取失败",
+            output="",
+            error="account_id 参数无效:必须是非空字符串",
+        )
+
+    if not account_id.startswith("MS4wLjABAAAA"):
+        logger.error("get_account_fans_portrait invalid sec_uid format", extra={"account_id": account_id})
+        return ToolResult(
+            title="账号粉丝画像获取失败",
+            output="",
+            error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
+        )
+
+    if len(account_id) < 70 or len(account_id) > 90:
+        logger.error("get_account_fans_portrait invalid sec_uid length", extra={"account_id": account_id, "length": len(account_id)})
+        return ToolResult(
+            title="账号粉丝画像获取失败",
+            output="",
+            error=f"account_id 长度异常:期望 70-90 字符,实际 {len(account_id)} 字符。这可能是编造或截断的数据。",
+        )
+
     try:
         payload = {
             "account_id": account_id,
@@ -100,24 +145,65 @@ async def get_account_fans_portrait(
                 summary_lines.append(f"  {name}: {ratio} (偏好度: {tgi})")
             summary_lines.append("")
 
+        duration_ms = int((time.time() - start_time) * 1000)
+        has_valid_portrait = bool(portrait and any(
+            isinstance(v, dict) and v for v in portrait.values()
+        ))
+
+        logger.info(
+            "get_account_fans_portrait completed",
+            extra={
+                "account_id": account_id,
+                "has_portrait": has_valid_portrait,
+                "portrait_dimensions": list(portrait.keys()) if portrait else [],
+                "duration_ms": duration_ms
+            }
+        )
 
         return ToolResult(
             title=f"账号粉丝画像: {account_id}",
             output="\n".join(summary_lines),
             long_term_memory=f"Fetched fans portrait for account '{account_id}'",
-            metadata={"raw_data": data}
+            metadata={
+                "raw_data": data,
+                "has_portrait": has_valid_portrait,
+                "portrait_data": portrait
+            }
+        )
+    except requests.exceptions.HTTPError as e:
+        logger.error(
+            "get_account_fans_portrait HTTP error",
+            extra={
+                "account_id": account_id,
+                "status_code": e.response.status_code,
+                "error": str(e)
+            }
         )
-    except httpx.HTTPStatusError as e:
         return ToolResult(
             title="账号粉丝画像获取失败",
             output="",
-            error=f"HTTP error {e.response.status_code}: {e.response.text}",
+            error=f"HTTP {e.response.status_code}: {e.response.text}",
+        )
+    except requests.exceptions.Timeout:
+        logger.error("get_account_fans_portrait timeout", extra={"account_id": account_id, "timeout": request_timeout})
+        return ToolResult(
+            title="账号粉丝画像获取失败",
+            output="",
+            error=f"请求超时({request_timeout}秒)",
+        )
+    except requests.exceptions.RequestException as e:
+        logger.error("get_account_fans_portrait network error", extra={"account_id": account_id, "error": str(e)})
+        return ToolResult(
+            title="账号粉丝画像获取失败",
+            output="",
+            error=f"网络错误: {str(e)}",
         )
     except Exception as e:
+        logger.error("get_account_fans_portrait unexpected error", extra={"account_id": account_id, "error": str(e)}, exc_info=True)
         return ToolResult(
             title="账号粉丝画像获取失败",
             output="",
-            error=str(e),
+            error=f"未知错误: {str(e)}",
         )
 
 
@@ -136,6 +222,8 @@ async def get_content_fans_portrait(
     """
     获取抖音内容点赞用户画像(热点宝数据)
 
+    获取指定视频内容的点赞用户画像数据,包括年龄、性别、地域等多个维度。
+
     Args:
         content_id: 抖音内容ID(使用 aweme_id)
         need_province: 是否获取省份分布,默认 False
@@ -148,15 +236,58 @@ async def get_content_fans_portrait(
         timeout: 超时时间(秒),默认 60
 
     Returns:
-        ToolResult: 包含点赞用户画像数据
-        - ratio: 占比(百分比)
-        - tgi (偏好度): > 100 表示该人群偏好高于平均水平,< 100 表示低于平均,= 100 表示平均水平
-          例如:50岁以上 tgi=150 表示该视频点赞用户中50岁以上人群的偏好度是平台平均的1.5倍
-
-    注意:
+        ToolResult: 包含以下内容:
+            - output: 文本格式的画像摘要
+            - metadata.has_portrait: 布尔值,表示是否有有效画像数据
+                - True: 有有效画像数据
+                - False: 无画像数据(需要使用账号画像兜底)
+            - metadata.portrait_data: 结构化的画像数据(字典格式)
+                - 键: 维度名称(如 "年龄"、"性别")
+                - 值: 该维度的分布数据(字典)
+                    - percentage: 占比(如 "48.35%")
+                    - preference: 偏好度/TGI(如 "210.05")
+            - metadata.raw_data: 原始 API 返回数据
+
+    Note:
+        - content_id 参数使用 aweme_id
         - 默认只返回年龄分布,需要其他维度时设置对应参数为 True
         - 省份数据只显示 TOP5
+        - 偏好度(TGI)说明:
+            - > 100: 该人群偏好高于平均水平
+            - = 100: 平均水平
+            - < 100: 低于平均水平
+        - 使用 metadata.has_portrait 判断画像是否有效,不要解析 output 文本
+        - 如果 has_portrait 为 False,应使用 get_account_fans_portrait 作为兜底
+        - 从 metadata.portrait_data 获取结构化画像数据
     """
+    start_time = time.time()
+
+    # 验证 content_id 格式
+    if not content_id or not isinstance(content_id, str):
+        logger.error("get_content_fans_portrait invalid content_id", extra={"content_id": content_id})
+        return ToolResult(
+            title="内容点赞用户画像获取失败",
+            output="",
+            error="content_id 参数无效:必须是非空字符串",
+        )
+
+    # aweme_id 应该是纯数字字符串,长度约 19 位
+    if not content_id.isdigit():
+        logger.error("get_content_fans_portrait invalid aweme_id format", extra={"content_id": content_id})
+        return ToolResult(
+            title="内容点赞用户画像获取失败",
+            output="",
+            error=f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}...",
+        )
+
+    if len(content_id) < 15 or len(content_id) > 25:
+        logger.error("get_content_fans_portrait invalid aweme_id length", extra={"content_id": content_id, "length": len(content_id)})
+        return ToolResult(
+            title="内容点赞用户画像获取失败",
+            output="",
+            error=f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位",
+        )
+
     try:
         payload = {
             "content_id": content_id,
@@ -204,44 +335,70 @@ async def get_content_fans_portrait(
                 summary_lines.append(f"  {name}: {ratio} (偏好度: {tgi})")
             summary_lines.append("")
 
+        duration_ms = int((time.time() - start_time) * 1000)
+        has_valid_portrait = bool(portrait and any(
+            isinstance(v, dict) and v for v in portrait.values()
+        ))
+
+        logger.info(
+            "get_content_fans_portrait completed",
+            extra={
+                "content_id": content_id,
+                "has_portrait": has_valid_portrait,
+                "portrait_dimensions": list(portrait.keys()) if portrait else [],
+                "duration_ms": duration_ms
+            }
+        )
+
         return ToolResult(
             title=f"内容点赞用户画像: {content_id}",
             output="\n".join(summary_lines),
             long_term_memory=f"Fetched fans portrait for content '{content_id}'",
-            metadata={"raw_data": data}
+            metadata={
+                "raw_data": data,
+                "has_portrait": has_valid_portrait,
+                "portrait_data": portrait
+            }
+        )
+    except requests.exceptions.HTTPError as e:
+        logger.error(
+            "get_content_fans_portrait HTTP error",
+            extra={
+                "content_id": content_id,
+                "status_code": e.response.status_code,
+                "error": str(e)
+            }
+        )
+        return ToolResult(
+            title="内容点赞用户画像获取失败",
+            output="",
+            error=f"HTTP {e.response.status_code}: {e.response.text}",
         )
-    except httpx.HTTPStatusError as e:
+    except requests.exceptions.Timeout:
+        logger.error("get_content_fans_portrait timeout", extra={"content_id": content_id, "timeout": request_timeout})
         return ToolResult(
             title="内容点赞用户画像获取失败",
             output="",
-            error=f"HTTP error {e.response.status_code}: {e.response.text}",
+            error=f"请求超时({request_timeout}秒)",
+        )
+    except requests.exceptions.RequestException as e:
+        logger.error("get_content_fans_portrait network error", extra={"content_id": content_id, "error": str(e)})
+        return ToolResult(
+            title="内容点赞用户画像获取失败",
+            output="",
+            error=f"网络错误: {str(e)}",
         )
     except Exception as e:
+        logger.error("get_content_fans_portrait unexpected error", extra={"content_id": content_id, "error": str(e)}, exc_info=True)
         return ToolResult(
             title="内容点赞用户画像获取失败",
             output="",
-            error=str(e),
+            error=f"未知错误: {str(e)}",
         )
 
-
 def _top_k(items: Dict[str, Any], k: int) -> List[Tuple[str, Any]]:
     def percent_value(entry: Tuple[str, Any]) -> float:
         metrics = entry[1] if isinstance(entry[1], dict) else {}
         return metrics.get("percentage")
 
     return sorted(items.items(), key=percent_value, reverse=True)[:k]
-
-
-# async def main():
-#     # result = await get_account_fans_portrait(
-#     #     account_id="MS4wLjABAAAAXvRdWJsdPKkh9Ja3ZirxoB8pAaxNXUXs1KUe14gW0IoqDz-D-fG0xZ8c5kSfTPXx",
-#     #     need_province=True
-#     # )
-#     result = await get_content_fans_portrait(
-#         content_id="7614821787578568420"
-#         # need_province=True
-#     )
-#     print(result.output)
-#
-# if __name__ == "__main__":
-#     asyncio.run(main())

+ 0 - 6
examples/production_template/README.md

@@ -280,9 +280,3 @@ MAX_ITERATIONS=100
 
 # 或使用异步任务队列(Celery、RQ 等)
 ```
-
-## 参考
-
-- [快速上手指南](../../QUICKSTART.md)
-- [框架文档](../../agent/README.md)
-- [示例项目](../content_finder/)