我把简历页变成了一个会说话的 Agent
这篇文章不是教程。
它是一次产品决策的复盘——我改了三次主意,踩了两个坑,最终发现”数据边界”才是 AI 产品最难的地方。
一、起点是一个不满意
我的 Astro 博客已经做得不错了:SEO、JSON-LD、Pagefind 全文搜索、多主题切换、MDX 文章渲染。但有一天我意识到,它是被动的。
招聘者打开 About 页,扫一眼就关了。PDF 简历、项目列表、技术栈标签——都是单向输出,没有对话,没有深度。
一个念头冒出来:如果它能回答问题呢?
不是那种预设 FAQ 的伪对话,而是真正理解问题、检索信息、组织语言回答的那种。
二、功能范围:最难的决策先来
我最初想要的东西很多:
- 展示简历、可下载 PDF
- 查询我的 GitHub 仓库代码片段
- 介绍项目经历、技术栈
- RAG 搜索博客文章
- 文件发送(像 IM 里的 PDF 资源卡片)
列出来就知道这是个泥潭。“不合理的功能砍掉” 是我对自己说的第一句话。
几轮取舍下来,核心范围收敛为:介绍我这个人。经历、项目、兴趣、博客,能对话、能展示卡片、有适当的视觉反馈。
然后是第一个关键架构决策。
三、不做独立后端
最初的方案是把 agent 做成独立服务(Hono + Railway),博客前端调用它。这是很自然的想法,前后端分离,各司其职。
但我停下来想了想:这意味着要维护两个部署,两套配置,还要处理跨域。对一个个人博客来说,这个复杂度没有价值。
把 agent 搬进 Astro 本身。
Astro 的 SSR 模式支持 API 路由,SSE 流式输出在 Cloudflare Workers 上完全可行。/api/chat 就是 agent 的入口,和博客部署在同一个 CF Pages 项目里。一个 wrangler.toml,一次部署,完事。
四、系统提示词越短越好
第二个关键决策,也是我认为最重要的一个。
最直觉的做法是把所有信息塞进 system prompt:教育背景、工作经历、项目介绍、兴趣爱好、联系方式……然后让 LLM 从这堆文字里找答案。
我没有这么做。
原因是:LLM 处理长 context 时会”偷懒”,靠近开头和结尾的信息权重高,中间的会被稀释。更重要的是,静态塞入的数据没有办法按需更新,每次改一个细节都要重新整理整段 prompt。
我选择了 Progressive Disclosure:system prompt 只描述角色和工具使用规则,所有具体信息通过工具按需检索。
访客问"你在哪里上的大学?"→ agent 调用 get_profile('education')→ 拿到结构化数据→ 组织语言回答工具分两类:
get_profile(category):精确检索,类别明确时用。education / experience / project / skills / hobby / contact / blog_namesearch_profile(query):模糊检索,跨类别或开放式问题用。BM25 全文搜索
展示工具独立出来:show_item_cards、show_field_card、show_media_grid、show_skills。检索和渲染解耦,LLM 决定”说什么”,工具决定”怎么显示”。
这个设计的好处在后来才充分体现出来——当我需要加一条新的个人数据时,只需要往 profile 数据文件里加一条记录,不需要动 prompt。
五、BM25 够用,直到中英文混搜时崩了
search_profile 背后最初用的是 BM25——一种经典的关键词匹配算法,速度快,不需要 embedding,本地就能跑。
但有一天我意识到一个问题:
我通常写的知识都是中文,例如我不会写 “limit break” 给它存的。
我的 profile 数据里,博客名的原因写的是:
"博客名来自游戏王的魔法卡「限制解除(リミッター解除)」"当访客用英文问 “why is this blog called Limit Break?” 时,BM25 完全搜不到——因为查询词是英文,索引是中文,没有重叠的 token。
这是 BM25 的根本限制:它不理解语义,只匹配词形。
六、Hybrid Search:CF Workers AI + 离线预计算
解法是 embedding 检索,让语义相近的内容可以被检索到,不管用什么语言问。
我选了 Cloudflare Workers AI 的 @cf/baai/bge-m3——一个多语言向量模型,支持中英日等语言的语义搜索,而且对 CF Pages 项目来说是免费的(每天 10,000 neurons 免费额度,个人博客基本够用)。
核心设计取舍: query-time embedding 还是离线预计算?
如果对每次对话都实时 embed profile 数据,延迟高、费用多。更合理的方式是:
- 构建时:对所有 profile 条目计算 embedding,存成静态 JSON
- 运行时:只对用户的 query 做一次 embedding,在内存里做向量相似度匹配
pnpm embed → profile-embeddings.json(40 条,~1MB)最终的检索流程是 BM25 + 向量 Reciprocal Rank Fusion:
BM25 结果(按词频排名) +向量结果(按语义相似度排名) ↓ RRF 融合(K=60) ↓ Top-K 结果两路结果互补:BM25 精确召回关键词匹配的条目,向量覆盖跨语言的语义相关内容。
七、Agent 开始说谎了
第一次完整测试,我问:“请用一句话介绍一下 Leo 是谁。”
Agent 回答:
“Leo Ji 是一名全栈工程师,曾在阿里巴巴和 Ola 等大厂工作,2024 年移居智利圣地亚哥……”
问题是,我从没在阿里巴巴或 Ola 工作过。
查了日志才发现:search_profile 返回的是 talking_point 类的数据(关于为什么做软件、复利积累这些),不包含具体的公司名。LLM 感知到”信息不足”,于是从训练数据里补了两家听起来合理的中国大公司。
这就是 hallucination——不是模型坏掉了,而是我没有给它足够清晰的边界。
修复分两层:
第一层:加规则。 在 system prompt 里明确写:教育和工作经历只能来自工具结果,如果工具没有返回,就说”这个信息我这里没有,建议直接联系 Leo”,而不是补充猜测。
第二层:改数据。 问题的根本是 search_profile 搜到的不是工作经历数据,因为”介绍 Leo”这个查询在中文 BM25 下匹配到了别的类别。这提示我需要把 experience 条目写得更容易被检索到。
Grounding 不是”调用了工具就安全了”。 工具返回了什么、LLM 在没有足够信息时会怎么填补——这才是真正需要设计的地方。
八、我还不知道的问题
Agent 现在能正确回答的问题越来越多。但我不知道它回答错了什么——因为我没有在场。
每次访客问了一个 profile 里没有的问题,agent 要么说”这个信息我没有”,要么(更糟的情况)悄悄编造了。我没有记录,没有复盘,没有改进。
这是下一步要做的事:数据反馈管线。
当 agent 触发”信息不足”的回答时,把这条 query 记录下来,形成一个缺口清单。下次维护数据时,从清单里找最常被问到但缺失的条目,针对性补充。
这不只是个技术问题,它让这个系统变成一个闭环:访客的问题告诉我哪里需要改进,改进又让下一个访客得到更好的答案。
结语
构建这个 agent 的过程让我意识到,AI 产品开发和普通功能开发有一个核心差异:
普通功能,你知道它什么时候坏了——报错、崩溃、返回错误值。
AI 产品,它坏掉时往往看起来没问题——答案流畅、语气自信,只是内容是假的。
所以最重要的不是让它回答更多问题,而是让它知道自己不知道什么。
这篇文章写的就是这个过程的开始。
这个 Agent 就在本页——你可以直接问它任何问题。如果它答不上来,那就是下一篇文章的素材。