我让 AI 守了七把虚构的剑——agenthatch v1.0.1 真实孵化手记

凌晨三点,我盯着终端里缓缓流出的"九星剑祖于剑冢前立誓曰"几个字,差点笑出声。

故事得从一周前说起。我在折腾一个叫 agenthatch 的开源 agent 生成框架。框架本身的卖点很直白:喂一份 SKILL.md 和一摞知识库 markdown,它给你吐一个能独立运行、带 RAG 检索、能跑多轮对话的 Python agent。

听起来挺干净。真上手你会发现,agent 这玩意儿跟养猫一样,买回来只是开始,真正耗你时间的全在后头。

一、为什么挑了个虚构的剑修世界

为了做版本宣发测试,我需要一个跟现实完全无关的 agent。

理由很简单。现实世界的知识库(LLM 训练时大概率见过)会污染测试结果,你分不清 agent 是真去检索了 KB,还是直接背了维基百科。我得造一个 LLM 见都没见过的世界,逼它老老实实走 retrieve 工具。

于是有了星河剑宗。

虚构东方剑修世界设定。开宗之夜,九星齐坠,一位无名流浪剑修循铁而至,以九星之铁铸成七剑,于剑冢前立宗开派。七剑分北斗、南辰两谱系,各有剑灵、剑诀、历代剑主。细节多到能写本修真小说。

我手写了七篇 markdown(七剑总录、北斗剑、天璇剑、玉衡剑、南辰紫微剑、剑宗源流、剑修术语),每一篇都尽量有具体细节。比如北斗剑灵"年约十二三岁,瞳色随天时而变:白昼为浅金,月夜为银蓝,星夜为深紫"。具体到这个程度,LLM 才编不出来。

二、第一次跑,翻车了

跑八轮连续对话。前五轮表现惊艳:七剑总览、北斗深入、剑诀追问、错误前提纠正、跨剑关联,全过。

第六轮翻车了。

提问:"请列出星河剑宗所有品阶为「天品」的剑,按品阶从高到低排列。"

终端输出四百多字的完整答案,带 markdown 表格,品阶排序、谱系归属、剑灵执掌一一列齐。屏幕上肉眼可见。

然后调用方拿到的返回值,空字符串。

三、抓 bug:为啥答案是空的

这就让人头大了。屏幕上明明有内容,API 层却返回空。难道是流式和同步两条路径分叉了?

我在 agent_loop.py 的 stream 方法里撒了一把 debug print,重启,跑。日志告诉我:RETURNING final_text len=466

也就是说,ConversationLoop.stream() 这边老老实实 return 了 466 字符。再往上,调用方收到的却是 0。

中间一定有人把返回值吃了。

往上翻一层,看到星河剑宗 agent 的 chat_stream

def chat_stream(self, user_input: str):
    self._pre_turn_workflow(user_input)
    yield from super().chat_stream(user_input)

看到了吗?

yield from 这个语法糖,会把子 generator yield 出来的值原样转发。但子 generator 的 return value 是被丢弃的。Python 文档里写得明明白白:yield from 作为表达式的值是 None,除非你显式写 return (yield from ...)

少了一个 return

就这一个 return,让所有 KB agent 在调用方眼里都"答了个寂寞"。文本流式出去了,但编程接口拿不到最终答案。

修复就一行:

return (yield from super().chat_stream(user_input))

顺手把代码生成模板 agent.py.j2 也改了,避免未来生成的 agent 重蹈覆辙。

四、bug 修了,但故事没完

R6 修好后,八轮对话全部通过。R7"追问指代"那个轮次尤其有意思。

我故意问得模糊:"它的第二式剑诀叫什么名字?"

上一轮聊的是天品四剑。"它"指代谁?北斗?紫微?天璇?南辰?

LLM 的反应很有意思。它先 acknowledge 了歧义("阁下未明示所指何剑"),然后基于上下文做了合理推断("然观前番对话,方才论及天品四剑,其中天璇剑与阁下所询最为相关"),给出天璇剑第二式"明心剑"的口诀,最后还补一句"若阁下所问另有其剑,不妨明示剑名"。

这种处理方式,比我见过的一些客服都体面。

五、meta-narration:那只会反复出现的幽灵

整个迭代过程中最耗我心力的,是一个叫"meta-narration"的现象。

LLM 在调用 task_complete 之前,总忍不住加几句"已完整解答,无剩余步骤"、"前已详答,此不赘述"、"用户的问题已在上一轮完整解答"。

这些话对用户毫无价值,纯粹是模型给自己壮胆的"自我总结"。

我在 agent_loop.py 里加了 _strip_trailing_meta_narration 函数,用正则匹配这些尾巴,整句删除。模式列表越加越长。从最初的"已完整解答"到现在 20 多个变体,覆盖"前问已答毕"、"已答毕"、"前文已详答"、"已详答"、"已作答"……

每次以为堵住了,LLM 总能变出新的措辞。这像跟一个会变戏法的朋友玩打地鼠。

后来才发现一个更隐蔽的 bug。chat_stream 路径里有个 typo:kb_max_text = 1,而 chat() 路径里是 0。注释还大言不惭写着"Same KB auto-continuation cap as chat()"。就这一个数字,让流式路径下"KB agent 模式"的判断永远不成立,裁剪函数压根没被调用过。

typo 修了,模式列表才算真正生效。

六、最后跑了一遍

最终测试结果:

pytest:               439 passed
_dialogue_xinghe.py:  7/7 通过(独立 context)
_dialogue_cli_test.py: 6/6 通过(多轮跨主题)
_dialogue_edge_test.py: 6/6 通过(边界场景)
_dialogue_rounds_test.py: 8/8 通过(多轮连续对话)
总历史污染 / meta-narration 残留: 0

八轮连续对话里包括:七剑总览、北斗深入、剑诀追问、错误前提纠正("开阳剑灵是银发少年吗?"——其实银发少年是北斗,开阳是双子剑灵)、跨剑关联、复杂多条件、追问指代、古风提问("敢问阁下,开宗之夜,九星齐坠,铸剑者谁?所遗之言何如?")。

古风提问那轮 LLM 答得尤其有腔调:"九星剑祖晚年于剑冢前证道,留下宗门第一祖训——「剑非剑,灵非灵,持剑者心,方为剑。」"

七、几点感悟

这次迭代让我重新认识了"agent 工程"这件事。

很多人把 agent 想得太简单。"调个 LLM,套个 prompt,加几个 tool 就完事了"。这种想法大概只对了一小半。真上手做你会发现:

  • prompt 工程大概占 30% 的工作,70% 在工程细节。context 管理、tool 调度、流式与同步两条路径、错误恢复、auto-continuation 抑制、meta-narration 裁剪,每一项都能让你熬到凌晨。
  • LLM 是个出戏的演员。它会自己加戏,会跑题,会自我总结,会把上下文搞混。你得给它写护栏,但又不能太死。
  • 测试是灵魂。我写了四个不同维度的对话测试脚本:独立 context、多轮跨主题、边界场景、多轮连续。每个维度抓到的 bug 都不一样。

最后想说的是,星河剑宗这个 agent 现在挂在 agenthatch 仓库的 tests/fixtures 里。它完全虚构,所有剑名、剑灵、剑诀都是我编的,适合做版本宣发的演示。LLM 见都没见过,所有答案必须老老实实走 KB 检索。

如果你想看 agent 工程的"反面教材",或者想试试自己写一个虚构世界的 agent,欢迎来玩。

GitHub:https://github.com/agenthatch/agenthatch

九星剑祖在剑冢前等你。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐