在 AI 时代做独立开发者,最不缺的是信息,最缺的是把信息变成行动线索的能力。IdeaLoop 灵感回路要解决的正是这个问题:它不是又一个 RSS 阅读器,也不是把新闻标题丢给大模型自动写摘要,而是围绕“采集、筛选、生成、验证、分发”构建一套日报系统。顺着这套回路,每天固定时间输入外部世界的 AI 动态和独立开发者关注的热点,经过模型筛选后输出一份带有判断、理由和行动建议的日报,而不是让信息继续堆在收藏夹里。
下面的实现会按最小可运行版本的思路走一遍。先拆场景,再准备环境,然后实现采集、生成、存储、输出四个核心模块,最后补上调度、验证、排查和生产化建议。技术栈选用 Python 3.11、FastAPI、SQLite 和一个兼容/v1/chat/completions的模型服务。这样在本地部署模型时能跑,在云上调用模型 API 时也能跑,切换成本很低。
1. 先想清楚“灵感回路”解决什么问题
1.1 独立开发者缺的不是信息,而是转化能力
独立开发者通常会订阅大量产品动态、AI 工具更新、开源项目 release、社区讨论帖,还会关注热搜词和行业报告。信息源足够多,真正稀缺的是每天能不能空出一段时间,把收到的信息转成“我明天可以验证什么”、“我的副业产品可以借鉴什么”这类具体问题。
IdeaLoop 灵感回路的第一个设计原则,是给信息增加一道“筛选层”。筛选不是简单按关键词过滤,而是让大模型先判断:这条信息对独立开发者有没有参考价值、可以归入哪个主题、是否值得进入今天的日报。筛选结果只要 5 条到 8 条,控制认知负荷,保证每条都能被认真看。
1.2 日报生成不是“自动小编”,而是把热词变成决策线索
许多日报工具做的是把标题和时间聚在一起,本质上还是一个更干净的新闻列表。IdeaLoop 不同,它希望把一条资讯转化成三个层次:
- 事实层:今天发生了什么,关键词是什么,涉及哪个产品、项目或领域。
- 判断层:为什么值得独立开发者关注,对应用开发、AI Agent、本地部署、编程效率有什么潜在影响。
- 行动层:如果要做副业或产品实验,可以从哪个小切入点开始验证。
大模型在这里承担的是“分析助手”,不是“内容搬运工”。它负责把输入材料里的零散信息整理成决策线索。这也是为什么 Prompt 比抓取逻辑更重要,后面会单独展开。
1.3 最小回路应该分成五个环节
一个可落地的 IdeaLoop 可以拆成以下五段:
| 环节 | 输入 | 输出 | 关键难点 |
|---|---|---|---|
| 采集 | RSS、热词、手动种子链接 | 原始条目列表 | 去重、标题清洗、发布时间归一化 |
| 筛选 | 原始条目 | 高价值候选条目 | 判断标准是否统一 |
| 生成 | 候选条目 | 结构化日报条目 | 模型输出是否稳定、可解析 |
| 验证 | 结构化 JSON | 校验通过的内容 | 字段缺失、主题偏移、重复生成 |
| 分发 | Markdown 日报 | 本地文件、Web 页面、消息推送 | 渠道可插拔、失败可重试 |
这五个环节不需要一次做完整。最小版本可以先做到“采集、生成、存储、输出”四步,验证环节先用脚本检查 JSON 字段和标题重复,后续再升级为人工审核或更多评分规则。
2. 环境准备:技术栈和项目结构要对齐
2.1 为什么选 Python + FastAPI + SQLite
Python 适合快速处理文本和调用模型接口,feedparser 可以解析 RSS,httpx 可以同步或异步请求模型服务。FastAPI 用来提供后续 Web 页面和 API 入口,比手动写 Flask 少很多样板代码。SQLite 则负责当天日报的幂等和去重,不需要额外启动数据库服务,适合个人工具和早期创业项目。
选型时不需要追求“生产级高可用”。最重要的是:一个人在不同机器上 clone 下来能快速运行、能改代码、能看日志。等日报规模变大、需要多人协作时,再替换成 PostgreSQL、任务队列和对象存储都来得及。
2.2 创建虚拟环境和安装依赖
先创建项目目录并初始化 Python 环境。
mkdir idealoop cd idealoop python3 -m venv .venv source .venv/bin/activate然后安装以下依赖。
pip install --upgrade pip pip install fastapi uvicorn feedparser httpx pydantic apscheduler python-dotenv依赖说明如下:
fastapi和uvicorn提供 Web 服务和 API 入口。feedparser解析 RSS 源。httpx调用模型服务,支持超时和异步。pydantic校验结构化数据。apscheduler做定时调度。python-dotenv读取.env配置文件。
到这里不需要急着安装任何与具体厂商绑定的 SDK。模型调用统一使用 HTTP 接口,后续换本地模型或换云服务商时,只改环境变量。
2.3 目录结构建议
下面是一个适合从小项目起步的目录结构。
idealoop/ ├── app.py # FastAPI 入口与调度启动 ├── collector.py # 采集 RSS、热词、手动种子 ├── generator.py # 调用模型生成结构化日报 ├── storage.py # SQLite 存储、幂等去重 ├── reporter.py # 生成 Markdown 日报 ├── config.py # 读取环境变量 ├── requirements.txt ├── .env.example ├── data/ # SQLite 文件目录 │ └── idealoop.db └── reports/ # 日报输出目录 └── 2026-08-06.md这个结构把不同职责放在不同文件里,后续扩展时不会把采集逻辑和生成逻辑混在一起。模块之间只通过函数返回值或 Pydantic 模型传递数据,避免 Python 项目常见的隐式全局状态。
2.4 配置文件这样写,后续切换模型不用改代码
在.env.example中写入以下配置。
IDEA_LOOP_LANGUAGE=zh-CN IDEA_LOOP_API_BASE=http://localhost:11434/v1 IDEA_LOOP_API_KEY=local IDEA_LOOP_MODEL=qwen2.5:7b IDEA_LOOP_TODAY_TOPICS=AI Agent,AI应用开发,独立开发者,AI短剧,AI绘画 IDEA_LOOP_DB_PATH=./data/idealoop.db IDEA_LOOP_REPORT_DIR=./reports IDEA_LOOP_MAX_ITEMS=6 IDEA_LOOP_TIMEOUT_SECONDS=60这些参数的含义如下:
| 参数 | 作用 | 默认值场景 |
|---|---|---|
IDEA_LOOP_API_BASE | 模型服务地址 | 本地模型指向localhost:11434/v1,云服务指向服务商地址 |
IDEA_LOOP_API_KEY | 认证密钥 | 本地模型可以填local,云端按服务商要求配置 |
IDEA_LOOP_MODEL | 模型名称 | 本地模型填实际拉取名称,云端填模型 ID |
IDEA_LOOP_TODAY_TOPICS | 当天关注主题 | 用英文逗号分隔 |
IDEA_LOOP_MAX_ITEMS | 日报最多输出几条 | 建议 5 到 8 |
IDEA_LOOP_TIMEOUT_SECONDS | 模型请求超时时间 | 本地模型可放宽到 120 秒 |
config.py用os.getenv读取这些配置,并设置合理默认值。
import os from dotenv import load_dotenv load_dotenv() def get_config() -> dict: return { "language": os.getenv("IDEA_LOOP_LANGUAGE", "zh-CN"), "api_base": os.getenv("IDEA_LOOP_API_BASE", "http://localhost:11434/v1"), "api_key": os.getenv("IDEA_LOOP_API_KEY", "local"), "model": os.getenv("IDEA_LOOP_MODEL", "qwen2.5:7b"), "topics": [ t.strip() for t in os.getenv( "IDEA_LOOP_TODAY_TOPICS", "AI Agent,AI应用开发,独立开发者,AI短剧,AI绘画", ).split(",") if t.strip() ], "db_path": os.getenv("IDEA_LOOP_DB_PATH", "./data/idealoop.db"), "report_dir": os.getenv("IDEA_LOOP_REPORT_DIR", "./reports"), "max_items": int(os.getenv("IDEA_LOOP_MAX_ITEMS", "6")), "timeout_seconds": int(os.getenv("IDEA_LOOP_TIMEOUT_SECONDS", "60")), }把配置集中在config.py里有两个好处:一是所有模块读同一份配置;二是测试时可以通过环境变量覆盖数据库路径和 API 地址,不影响实际数据。
3. 先跑通一个最小可实现版本
3.1 数据采集:从 RSS、热词和手动种子拿到原始内容
采集模块负责把外部信息变成统一格式的原文列表。为了不让文章过长,这里只实现两个来源:RSS 列表和手动种子文本。
# collector.py import hashlib import re from dataclasses import dataclass, field import feedparser @dataclass class RawItem: source: str title: str content: str link: str = "" published: str = "" raw_id: str = field(default="") def __post_init__(self): if not self.raw_id: text = f"{self.source}:{self.title}:{self.link}".strip() self.raw_id = hashlib.sha1(text.encode("utf-8")).hexdigest()[:16] def _clean_text(text: str, max_len: int = 500) -> str: text = re.sub(r"\s+", " ", text or "") return text[:max_len] def collect_from_rss(rss_urls: list[str], max_per_source: int = 10) -> list[RawItem]: items: list[RawItem] = [] for url in rss_urls: try: feed = feedparser.parse(url) for entry in feed.entries[:max_per_source]: title = _clean_text(entry.get("title", ""), 200) content = _clean_text(entry.get("summary", entry.get("description", "")), 500) link = entry.get("link", "") items.append( RawItem( source=url, title=title, content=content, link=link, published=entry.get("published", ""), ) ) except Exception as exc: print(f"[collector] RSS 解析失败 {url}: {exc}") return items def collect_from_seed(seeds: list[str]) -> list[RawItem]: items = [] for seed in seeds: title = _clean_text(seed, 200) items.append( RawItem( source="manual-seed", title=title, content=title, link="", published="", ) ) return items实现里用raw_id作为去重主键。同一标题、同一链接的内容只保留一次。这也是后续幂等写入的基础。
3.2 生成日报:用本地或 API 模型做筛选和提炼
生成模块是 IdeaLoop 的核心。它把原始条目列表交给模型,要求模型返回一个 JSON 数组,每条内容包括主题、标题、摘要、价值和行动建议。
# generator.py import json import httpx SYSTEM_PROMPT = """ 你是一个面向独立开发者的 AI 趋势分析师。 你需要从用户提供的原始内容列表中,筛选出真正有启发价值的 3 到 6 条内容。 筛选标准: 1. 主题涉及 AI 应用、AI Agent、AI 编程、AI 内容生产、独立开发者工具、副业实验。 2. 信息对未来 7 天内的产品决策有参考价值。 3. 避免重复、广告、纯八卦和无结论的信息。 只输出 JSON,格式如下: [ { "topic": "所属主题", "title": "短标题", "summary": "不超过 80 字的事实摘要", "reason": "为什么值得关注", "action": "独立开发者可以尝试的行动建议" } ] """.strip() def build_user_prompt(items: list[dict], topics: list[str]) -> str: lines = [f"今日关注主题:{', '.join(topics)}", "", "原始内容列表:"] for item in items: lines.append(f"- 标题:{item['title']}") if item.get("content"): lines.append(f" 摘要:{item['content']}") if item.get("link"): lines.append(f" 链接:{item['link']}") return "\n".join(lines) def generate_report( api_base: str, api_key: str, model: str, items: list[dict], topics: list[str], timeout: int = 60, ) -> list[dict]: payload = { "model": model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": build_user_prompt(items, topics)}, ], "temperature": 0.2, "response_format": {"type": "json_object"}, } headers = {"Authorization": f"Bearer {api_key}"} try: with httpx.Client(timeout=timeout) as client: resp = client.post( f"{api_base.rstrip('/')}/chat/completions", headers=headers, json=payload, ) resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] parsed = json.loads(content) if isinstance(parsed, list): return parsed if isinstance(parsed, dict) and "items" in parsed: return parsed["items"] return [] except Exception as exc: print(f"[generator] 模型调用或解析失败: {exc}") return []需要说明的是,不同模型服务对response_format的支持不完全一致。如果请求报错,可以去掉这个字段,让模型返回普通文本后再用代码抽取 JSON。后面排查章节会再次提到这个坑。
3.3 存储与输出:SQLite 负责幂等,Markdown 负责阅读
日报文件需要反复生成,但不能同一天生成多份相互矛盾的版本。SQLite 用report_date作为唯一键,同一天再次运行时先删除旧数据再写入新数据,这样既保证结果可重试,又不会堆积重复数据。
# storage.py import json import sqlite3 from pathlib import Path def init_db(db_path: str) -> None: Path(db_path).parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS daily_reports ( report_date TEXT PRIMARY KEY, payload_json TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() conn.close() def save_report(db_path: str, report_date: str, payload: list[dict]) -> None: init_db(db_path) conn = sqlite3.connect(db_path) conn.execute("DELETE FROM daily_reports WHERE report_date = ?", (report_date,)) conn.execute( "INSERT INTO daily_reports (report_date, payload_json) VALUES (?, ?)", (report_date, json.dumps(payload, ensure_ascii=False)), ) conn.commit() conn.close()输出模块把已经写入数据库的日报渲染成 Markdown。
# reporter.py from pathlib import Path from storage import save_report def render_markdown(report_date: str, payload: list[dict]) -> str: lines = [f"# IdeaLoop 灵感日报 {report_date}", ""] for idx, item in enumerate(payload, start=1): lines.append(f"## {idx}. {item.get('title', '未命名主题')}") lines.append("") lines.append(f"- 主题:{item.get('topic', '未分类')}") lines.append(f"- 摘要:{item.get('summary', '')}") lines.append(f"- 价值:{item.get('reason', '')}") lines.append(f"- 行动建议:{item.get('action', '')}") lines.append("") return "\n".join(lines) def write_report( db_path: str, report_dir: str, report_date: str, payload: list[dict], ) -> str: save_report(db_path, report_date, payload) Path(report_dir).mkdir(parents=True, exist_ok=True) report_path = Path(report_dir) / f"{report_date}.md" report_path.write_text( render_markdown(report_date, payload), encoding="utf-8", ) return str(report_path)这里把“存储”和“输出”分开,之后再接入邮件或 IM 机器人时,可以只增加新输出函数,不需要改数据库逻辑。
3.4 调度入口:命令行和定时任务两条路
把采集、生成、存储、输出串起来的入口放在app.py。
# app.py from datetime import date from fastapi import FastAPI from apscheduler.schedulers.background import BackgroundScheduler import collector import generator import reporter from config import get_config app = FastAPI(title="IdeaLoop") scheduler = BackgroundScheduler() RSS_SOURCES = [ "https://example.com/rss/ai.xml", "https://example.com/rss/developer.xml", ] def run_daily_job(report_date: str | None = None) -> dict: cfg = get_config() target_date = report_date or date.today().isoformat() raw_items = [] for source in RSS_SOURCES: raw_items.extend(collector.collect_from_rss([source], max_per_source=5)) manual_seeds = [ "AI Agent 在企业内部知识库中的应用", "独立开发者用 AI 短剧制作工具验证内容付费", "本地部署 AI 模型之后,如何做私有知识库问答", ] raw_items.extend(collector.collect_from_seed(manual_seeds)) # 在调用模型前,先把原始条目转换成 dict items_for_llm = [ { "title": item.title, "content": item.content, "link": item.link, "raw_id": item.raw_id, } for item in raw_items ] payload = generator.generate_report( api_base=cfg["api_base"], api_key=cfg["api_key"], model=cfg["model"], items=items_for_llm, topics=cfg["topics"], timeout=cfg["timeout_seconds"], ) report_date_to_use = target_date # 如果模型没有返回内容,至少保证输出一个空日报,便于排查 if not payload: payload = [ { "topic": "空日报", "title": "今天没有筛选到高价值内容", "summary": "请检查采集源、模型服务和 Prompt 配置", "reason": "避免日报直接断裂,便于定时任务记录状态", "action": "查看日志并手动重跑", } ] path = reporter.write_report( db_path=cfg["db_path"], report_dir=cfg["report_dir"], report_date=report_date_to_use, payload=payload, ) return {"date": report_date_to_use, "items": len(payload), "path": path} @app.on_event("startup") def on_startup() -> None: scheduler.add_job( run_daily_job, "cron", hour=8, minute=30, id="idealoop_daily", replace_existing=True, ) scheduler.start() @app.get("/health") def health() -> dict: return {"status": "ok"} @app.get("/run") def run_endpoint(report_date: str | None = None) -> dict: return run_daily_job(report_date)这样既可以用命令行直接触发,也可以启动 Web 服务后用/run接口手动执行。
3.5 运行验证:生成 2026-08-06 的日报
先创建数据目录,然后启动 Web 服务。
mkdir -p data reports cp .env.example .env uvicorn app:app --reload --port 8000在另一个终端请求手动执行。
curl "http://127.0.0.1:8000/run?report_date=2026-08-06"正常响应类似:
{ "date": "2026-08-06", "items": 5, "path": "reports/2026-08-06.md" }打开reports/2026-08-06.md,可以看到模型生成的日报条目。如果采集源为空或模型服务没启动,响应仍然包含一条空日报,但items会是 1,这时需要按第 6 节的排查步骤处理。
4. 关键细节:Prompt、结构化输出和幂等
4.1 把 Prompt 设计成“先选择、后解释”
很多日报生成失败,不是模型能力不够,而是 Prompt 没有定义清楚“什么内容值得进入日报”。可以这样设计:
- 先给一组筛选标准,让模型做选择题。
- 再让模型对选中的条目做解释。
- 最后要求输出 JSON,减少解析成本。
上面generator.py中的SYSTEM_PROMPT就是这么设计的。它没有让模型“自由发挥”,而是限制了主题范围、筛选标准和输出结构。这样日报内容不会变成一篇泛泛而谈的行业观察,而是围绕独立开发者真正关心的方向展开。
4.2 用 JSON 约束输出,避免“读不懂结果”
在调用模型时,可以声明"response_format": {"type": "json_object"},很多模型服务支持这一参数。它会让模型尽量返回合法的 JSON,而不是在代码块里夹杂说明文字。
即使设了这个参数,仍然要在代码里做二次兜底:
- 尝试
json.loads(content)。 - 如果失败,用正则提取
[和]之间的部分再解析。 - 如果解析结果不是列表,检查是否包含
items字段。
下面是一个兜底解析函数。
import json import re def parse_llm_json(content: str) -> list[dict]: content = content.strip() try: data = json.loads(content) if isinstance(data, list): return data if isinstance(data, dict) and isinstance(data.get("items"), list): return data["items"] except json.JSONDecodeError: pass match = re.search(r"\[.*\]", content, re.S) if match: try: data = json.loads(match.group(0)) if isinstance(data, list): return data except json.JSONDecodeError: pass return []这段代码放到生产环境很值钱。它不会让整个日报任务因为一条多余的提示文本而失败。
4.3 幂等去重:同一天重复执行不会重复生成
日报任务如果依赖外部 API,常常会发生“网络超时但服务端已经生成”的情况。幂等设计可以让重试变成安全操作。
当前实现中,daily_reports表以report_date为主键,写入时先DELETE再INSERT。这样每天只有一份结果,重复执行不会产生多条记录。Markdown 文件同理,每次写入都会覆盖同名文件。
这里要注意:删除再写入虽然解决了重复,但也意味着如果两次生成结果不同,前一份结果会被覆盖。因此,如果后续要保留历史版本,可以增加一个version字段,或把文件写入到带时间戳的路径。
4.4 成本与速度控制:模型选择、超时和缓存
大模型调用在个人项目中也要控制成本。不要每天都让模型读五百条原文,然后生成十条日报。更合理的做法是先做关键词初筛,再让模型处理候选集。
下面是一个简单的初筛示例:
def prefilter(items: list[dict], topics: list[str]) -> list[dict]: topic_text = " ".join(topics) return [ item for item in items if any( kw.lower() in (item["title"] + item.get("content", "")).lower() for kw in topic_text.split() ) ]初筛之后,模型输入数量可以减少一半以上。还能用缓存进一步减少重复请求,例如按raw_id做最近 7 天的去重。
模型参数也要合理设置:
| 参数 | 建议值 | 效果 |
|---|---|---|
temperature | 0.2 至 0.4 | 减少随机编造,输出更稳定 |
max_tokens | 800 至 1200 | 控制每日日报长度和成本 |
timeout | 60 至 120 秒 | 本地模型可能慢,云端 API 通常几秒返回 |
max_items | 5 至 8 | 信息密度优先,不做长篇报告 |
5. 从本机脚本到生产级服务
5.1 学习环境跑通后,先不要上微服务
最小版本在本地能跑通后,不要立刻拆成采集服务、生成服务、存储服务。独立开发者的效率来自“一个人能维护整个系统”,模块化但单体部署是最合适的阶段。
可以先把run_daily_job放入 FastAPI 的定时任务里,再找一个长期运行的服务器部署。数据库继续用 SQLite,但要保证data目录有备份策略。等日报数据量增大,或需要多端并发写入时,再迁移到 PostgreSQL。
5.2 配置、日志、监控和回滚
生产环境需要补齐以下能力:
- 配置外置:
.env不要提交到 Git 仓库,生产环境使用环境变量注入。 - 日志:每次执行记录日期、原始条目数、选中条目数、模型名称、耗时。
- 监控:至少监控模型 API 失败率、超时次数、日报生成是否为空。
- 回滚:如果某天日报质量明显下降,可以手动用前一天模板重新生成,或用 Git 回滚 Prompt 改动。
可以把执行日志输出到文件。
uvicorn app:app --host 0.0.0.0 --port 8000 --log-level info >> idealoop.log 2>&1脚本里也加一段耗时记录。
import time def run_daily_job(report_date: str | None = None) -> dict: start = time.time() ... result["elapsed_seconds"] = round(time.time() - start, 2) return result这样每次执行都能看到耗时,便于发现模型变慢或采集源异常。
5.3 分发渠道:Web 页面、邮件或 IM 机器人
目前只有 Markdown 文件,对个人查看足够。要做成产品,还需要一个分发层。
可以先给 FastAPI 增加一个页面,直接渲染最近一天的日报。
from fastapi.responses import HTMLResponse @app.get("/today", response_class=HTMLResponse) def today_page(report_date: str | None = None) -> str: cfg = get_config() target_date = report_date or date.today().isoformat() sqlite_path = cfg["db_path"] # 从 SQLite 读取 payload import sqlite3 conn = sqlite3.connect(sqlite_path) row = conn.execute( "SELECT payload_json FROM daily_reports WHERE report_date = ?", (target_date,), ).fetchone() conn.close() if row is None: return "<h1>今日日报未生成</h1>" payload = json.loads(row[0]) return render_markdown(target_date, payload).replace("\n", "<br>")这只是示例,实际页面建议用 Jinja2 模板。邮件推送则可以把 Markdown 转成 HTML 邮件,IM 机器人则发送纯文本或自定义消息卡片。
分发层的关键是保持“输出源”只有一份数据。Mail、Web、IM 都从同一个daily_reports表读取,避免三份数据三套逻辑。
5.4 数据安全和内容合规基线
日报会消费外部信息,也可能引用开源项目、产品动态和用户提交的链接。个人使用无所谓,但一旦做成公开服务,需要注意:
- 不采集和发布违法、敏感或涉及隐私的内容。
- 引用外部文章时尽量保留原文链接,避免大段复制。
- 对用户上传的种子内容做必要过滤。
- 模型 API 的 Key 不要出现在前端页面或日志中。
- 如果开放
/run接口,必须加上认证,避免被他人频繁调用产生费用。
这些不是额外负担,而是一个独立开发者产品能长期稳定运行的地基。
6. 常见问题排查:灵感日报为什么没有产出
6.1 采集结果为空
现象:运行后响应里的items是空日报,日志里没有任何候选条目。
可能原因:
- RSS 链接不可访问。
feedparser解析出的entries为空。- 手动种子列表为空。
- 初筛逻辑把候选全部过滤掉了。
检查方式:
python -c "import feedparser; print(feedparser.parse('https://example.com/rss/ai.xml').entries)"如果 entries 为空,说明 RSS 源本身不可用或网络受限。解决方法是更换可用源,或先把手动种子作为兜底来源。
6.2 模型返回内容无法解析
现象:日志出现[generator] 模型调用或解析失败: ...,日报只有空条目。
可能原因:
- 模型服务地址不对。
- API Key 失效。
- 模型不支持
response_format。 - 返回内容不是合法 JSON。
检查方式:
先手动用curl请求同一个接口。
curl -X POST http://localhost:11434/v1/chat/completions \ -H "Authorization: Bearer local" \ -H "Content-Type: application/json" \ -d '{"model":"qwen2.5:7b","messages":[{"role":"user","content":"返回一个 JSON"}],"response_format":{"type":"json_object"}}'如果接口报错,去掉response_format再试。代码中保留第 4.2 节的兜底解析,能显著降低失败概率。
6.3 当天重复生成多份重复日报
现象:reports目录出现多个同一天的文件,或 SQLite 中同一天有多条记录。
可能原因:
- 调度任务重复注册,启动多个 Uvicorn 进程。
- 手动执行
/run与定时执行没有加锁。 - 写入逻辑没有按
report_date去重。
当前代码已经用report_date主键规避了大部分问题。如果要更严格,可以加一个单机文件锁,防止多个进程同时写。生产环境建议把调度拆成单独进程,并把执行结果写入可靠日志。
6.4 定时任务没有执行
现象:手动执行/run正常,但早上 8:30 没有生成日报。
可能原因:
- FastAPI 的
@app.on_event在新版本中已经不建议使用,但当前能跑。 - Uvicorn 服务没有持续运行。
- 调度器启动时报错,被日志吞掉。
- 服务器时区设置与预期不同。
检查方式:
# 启动时查看输出 uvicorn app:app --host 0.0.0.0 --port 8000启动后看是否有Scheduler started之类的日志。没有的话,检查时区设置:
date如果服务器是 UTC 时区,8:30 会被解释成 UTC 时间。建议在run_daily_job中显式传入日期,或配置系统时区为本地时区。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 采集结果为空 | RSS 源失效、初筛太严 | 打印候选数量、测试 feedparser | 更换 RSS 源,调整初筛关键词 |
| 模型输出不能解析 | 接口不支持 JSON 模式、返回错误 | curl 请求接口、查看返回值 | 去掉 response_format,使用兜底解析 |
| 重复日报 | 多进程运行、调度重复注册 | 查看进程、查看 SQLite | 使用 report_date 唯一键,加进程锁 |
| 定时任务不执行 | 服务未启动、时区不对 | 查看启动日志、date 命令 | 确认调度注册,显式传入日期 |
7. 把“灵感日报”从工具变成习惯
7.1 固化三条工程规范
只要这个项目持续运行三个月,下面三条规范能避免大量无效维护:
- 每次修改 Prompt 都记录版本。Prompt 是日报质量的源头,没有版本就没有回滚依据。
- 模型输出必须做字段校验。缺少
summary或action的条目宁可丢弃,也不要进入日报。 - 每天生成后手动扫一眼。哪怕只看 30 秒,也能发现主题偏移和重复信息。
这三条比增加复杂架构更能保证日报的长期质量。
7.2 给独立开发者的灵感来源分级
不是所有信息都值得进入同一份日报。可以把来源分成三层:
| 层级 | 来源示例 | 主要用途 |
|---|---|---|
| 基础层 | 官方技术博客、开源项目 release、开发者社区 | 了解工具和框架变化 |
| 趋势层 | AI 应用案例、产品榜单、热搜词 | 寻找副业方向 |
| 验证层 | 用户反馈、数据分析、竞品动态 | 帮助产品迭代决策 |
采集策略可以按层级分别设置 RSS 源,并在 Prompt 中告诉模型不同层的权重。基础层保证专业度,趋势层提供灵感,验证层联系自身产品。
7.3 从每日日报延伸到每周复盘
IdeaLoop 最有价值的地方不是“每天生成”,而是“每天生成之后积累数据”。一周后可以统计:
- 哪些主题被模型反复选中。
- 哪些行动建议实际被记录却一直没有执行。
- 哪些信息源连续多天没有产生高价值条目。
这些数据能反过来优化采集源和 Prompt。比如连续一周都选中 AI Agent 相关主题,就可以增加一个子主题“AI Agent 落地场景分析”,让日报更加聚焦。
7.4 扩展方向:从个人工具到小产品
这个最小版本跑稳后,可以按以下顺序扩展:
- 增加用户自定义主题,用户选择自己的“关注领域”。
- 增加日报订阅,每天通过 Webhook 推送到邮箱或 IM。
- 增加历史日报浏览页,支持按日期和主题筛选。
- 增加点赞和收藏,作为信号回传,让模型根据反馈调整筛选。
- 把同一套“采集、筛选、生成、验证、分发”逻辑复用到周报、竞品监控、行业情报等场景。
需要注意的是,每一步扩展都要回到主线:帮助独立开发者把信息变成行动线索。如果没有服务好这个目标,功能越多越容易变成又一个信息堆积工具。
7.5 新手最容易忽略的四件事
最后列几个实际踩过的坑:
- 直接拿生产环境 API Key 写进代码仓库,导致泄露。正确的做法是从一开始就用
.env和环境变量隔离。 - 忽视了模型输出的不确定性,没有做 JSON 兜底解析,导致定时任务经常失败。
- 把采集、生成、存储耦合在一个超长脚本里,后续想换模型或加推送时改动成本很高。
- 没有记录每天的执行日志,出了问题不知道是模型超时还是采集源故障。
把最小版本跑起来只是起点。真正值得投入时间的是持续观察日报质量,调整 Prompt、采集源和筛选规则。当日报连续一个月都能在五分钟内给出可执行的副业或产品灵感时,IdeaLoop 才算真正形成了“灵感回路”。