你无法为品味做单元测试

Hacker News Top 新闻

摘要

一位开发者分享了他们为跑步应用构建兴趣点功能的经历,使用了Geonames数据、DuckDB以及Claude的AI辅助,遇到了偏见和幻觉的挑战。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/25 14:11

# 你无法为品味编写单元测试 来源:https://dev.karltryggvason.com/you-cant-unit-test-for-taste/ 我正在开发*In the Long Run*(https://inthelongrun.app/),一个让跑者在世界各地著名路线上进行*virtual*(虚拟)跑步的应用。该应用会汇总你的 Strava 里程数,并将你的总距离绘制为跨国家或跨大陆路线的进度。其目的是提供长期激励和动力;人生是一场马拉松,而不是百米冲刺。你可能经历糟糕的一个月或一个赛季,但仍能在虚拟环游世界的过程中取得进展。 该应用在交互式地图上展示你的进度,用户也可以自行探索。但我一直想在地图上增加有趣的景点或历史遗址。对于我熟悉的路段,我可以自己构建这样的列表,但对于跨越多国、我不熟悉的路段,这就无法扩展了。因此,我开始寻找一个兴趣点(POI)数据源,以便构建一个数据处理管道。在此过程中,我面对了品味与偏见的问题,还与一个会产生幻觉的大语言模型(LLM)斗争了一番。最初我以为AI将是核心功能,但最终它只是与其他信号和数据处理支柱一起扮演了辅助角色。 GeoNames(https://www.geonames.org/)是一个显而易见的起点,它是一个包含位置、类别和链接的广泛数据源。完整数据集可以下载,且采用知识共享许可协议。于是,我与我的朋友Claude一起开始构建一个管道,从原始数据导出到为*In the Long Run*的用户提供相关的兴趣点。 我们使用Python作为编程语言(对当前任务有良好的库支持),将处理后的数据本地存储为Apache Parquet文件,并使用DuckDB作为查询层¹(https://dev.karltryggvason.com/you-cant-unit-test-for-taste/#fn:1)。这是我第一次使用Parquet和DuckDB,两者的使用体验都不错,Claude逐步向我介绍了它们的功能(而且DuckDB的大部分工作是用我非常熟悉的SQL完成的)。总的来说,我认为在一个项目中引入一两个新工具或技术是学习的最佳方式。如果整个技术栈都是新的,学习曲线会过于陡峭,可能会让你完全放弃这个项目。AI编码助手在一定程度上改变了这种计算方式,但即便如此,我发现对*大部分*正在使用的技术有所掌握,能让我更好地引导AI,并做出明智的决策,而不是盲目跟随它的引导。 兴趣点功能截图,显示一位跑者在66号公路伊利诺伊州斯普林菲尔德附近的情况。 在开始实施之前,我与Claude一起制定了一个项目计划,概述了管道和功能的各个步骤。在实际进行中,我们为每一步制定了规范/计划,以便在从早期工作中学习更多后可以迭代。这也意味着我可以在每个里程碑开启新的AI代理会话。将前一个里程碑的结果浓缩成简短的上下文和指令,用于下一步,可以获得更快更好的响应(我发现大上下文会迅速降低AI代理工作的质量)。 ## 知名性与显著的偏见 (https://dev.karltryggvason.com/you-cant-unit-test-for-taste/#notability-and-notable-biases) 首先,我们从GeoNames(https://download.geonames.org/export/dump/)下载并解压所有必需的文件,并为数据文件设置了.gitignore,因为大部分文件太大,无法进行版本控制。 处理的第一步是将下载的文件按相关列连接,并过滤掉对我们无用的行。例如,我们排除了行政区划:国家、州、地区等。我们还选择了一些我们认为最有趣的特征代码:公园、历史遗址、城堡、纪念碑、山脉等²(https://dev.karltryggvason.com/you-cant-unit-test-for-taste/#fn:2)。最后,我们对居民点增加了人口过滤,对山脉增加了海拔过滤。我确信这导致了一些假阴性,但我们想要一个粗略的初稿。 有点不直观的是,`alternateNames.txt` GeoNames数据集包含维基百科链接(其中`isolanguage=link`并且`alternate_name like %en.wikipedia.org%`,这个用例感觉是事后才添加到其模式中的,但这是非常有用的数据)。我们将此作为知名度/相关性信号,它还提供了我们可以从中构建简短描述的文本,因为维基百科摘要也具有知识共享许可协议。 我们为这个管道步骤构建了一个基本的健全性检查,帮助我们验证我们没有跳过著名的地标,这让我们能够调整一些过滤条件。例如,初稿中包含了澳大利亚偏远地区的小镇Stonehenge(https://en.wikipedia.org/wiki/Stonehenge,_New_South_Wales),但没有包含史前巨石结构(其更著名的同名者)。在处理英语时,你还需要确保拉入相关的替代名称/语言,并使用相关的维基百科URL作为交叉参考(GeoNames以当地语言存储规范名称)。 这一步骤的最终结果是一个Parquet文件,包含全球约72.5万行兴趣点。与最初开始的完整原始数据集中的1300万行相比,这是一个显著的缩减。 按特征类划分的初选候选集。居民点是GeoNames数据集的主体,但我们不希望兴趣点只显示沿途的每个城镇、村庄和小村落。 在第二步中,我们将第一步中的所有候选点与我们拥有的每条路线(https://inthelongrun.app/routes)进行匹配。首先,我们获取路线的GeoJSON文件,并构建一个包围盒,以快速过滤出仅靠近路线的点。然后,我们遍历路线坐标,检查包围盒内的哪些点也落在路线本身的一定距离内(默认为50公里)。我们使用Shapely和Pyproj进行地理计算,并计算“*沿路线距离*”属性,以便决定“*何时*”向跑者展示兴趣点。 这一步骤的输出是一个特定于路线的Parquet文件,用于进一步优化路线。对于我们的冰岛环岛路线(1,321公里),我们得到了511个兴趣点;对于应用中路线最长的开普敦到马加丹(23,257公里),我们得到了1万个兴趣点;而66号公路(3,787公里)则得到了14,181个兴趣点。这早早地暗示了我们,我们使用的英语维基百科信号实际上是一个“*英语使用者住在哪里以及在哪里编辑维基*”的偏见。 ## LLM会说谎,但它有品味 (https://dev.karltryggvason.com/you-cant-unit-test-for-taste/#the-llm-lies-but-it-does-have-taste) 在第三步中,我们用维基百科信息丰富了现有数据,并使用LLM为每个兴趣点生成评级。起初我还打算使用LLM生成的兴趣点摘要,但这被证明是一个重大挑战且收益甚微。 首先,我们为给定路线的每个点获取维基百科摘要。对于每个维基百科URL,我们同样查询维基数据,查看有多少种语言的维基百科有关于该主题的文章。这是另一个很好的知名度信号:如果一个页面以多种语言存在,它可能比只有英语维基百科条目的页面更重要。维基数据我们可以全局缓存;这样,如果后续路径使用相同的点,我们可以避免重新获取。 维基数据也是LLM驱动步骤的输入。我们创建了一个工具(https://www.anthropic.com/engineering/advanced-tool-use),并调用它以获取结构化数据返回。Anthropic的Haiku模型因速度和价格而被选中(毫不意外地,它是由其“姊妹模型”Opus通过Claude Code推荐的),并且我们采用了批处理调用(https://platform.claude.com/docs/en/build-with-claude/batch-processing)以进一步节省成本(输入和输出令牌都打五折)。这是我第一次以这种方式编程调用LLM,API很有道理,但其输出并不完全一致。例如,有时Anthropic标记语言(antml)的奇怪变体会泄漏到工具调用结果字符串中,需要清理。批处理工具调用可能需要数小时才能完成,对于较大的路线,成本约为10美元。我想尝试使用本地或更便宜的模型来看看折衷方案是什么。 在这里我们也发现了一些幻觉。第一次尝试没有充分“立足”LLM在一个数据基础上,也没有在提示中应用限制。这意味着Haiku将伊利诺伊州迪凯特(https://en.wikipedia.org/wiki/Decatur,_Illinois)的中央公园归类为其在曼哈顿更著名的同名公园,并使得其重要性大幅提升。在第二次尝试中,我们添加了位置和管理元数据(国家、城市等)作为LLM的输入,并在系统提示中更仔细地使其立足。即便如此,我的抽查还是发现了几个幻觉:Haiku改变了城镇的人口规模,并让山脉变得比实际大得多(就像休·格兰特在那部90年代经典电影(https://en.wikipedia.org/wiki/The_Englishman_Who_Went_up_a_Hill_but_Came_down_a_Mountain)中那样)。 在那个时候,我决定直接回归到维基百科摘要。LLM生成的文本对于我们来说通常读起来更好,但正确性感觉比可读性更重要³(https://dev.karltryggvason.com/you-cant-unit-test-for-taste/#fn:3)。你可以在输入侧尝试不同的输入数据和提示,并在验证侧构建评估,但最终我觉得这不值得花时间和成本(LLM输出令牌比输入更贵)。这一挑战令人兴奋,但在代码等更容易验证的领域之外很难应对(构建集成测试来验证文本听起来像是一个维特根斯坦式的任务)。 我仍然使用LLM为兴趣点提供一个评级,该评级与特征代码和维基语言计数一起用于计算重要性分数。仅依赖维基数据会将大量权重赋予每一个拥有150种语言自动翻译维基页面的小镇。从LLM那里获得一个更“*主观*”的评级有助于提升每条路线上更“有趣”的兴趣点。 到目前为止获得最高评级的兴趣点。我怀疑雷克雅未克获得10分是因为提示中明确提到了它。它是一个首都,但它比芝加哥或洛杉矶更重要吗?瓦特纳冰盖呢?我不确定。 因此,LLM从写作任务(因为它编造东西)中被降级,但提升到提供其权重中潜在的“主观”品味。总体而言,这一步改变了我对这个新技术的看法:从它成为新功能的基础(“*AI解决了这个问题*”)到AI只是更大工具箱中的一个新工具(“*AI很好地补充了其他传统方法*”)。 除了构建管道本身,我们还沿途构建了一些工具来对各阶段进行健全性检查和调试。例如,一个基于Leaflet的可视化工具,将兴趣点放置在地图上以验证位置并预览最终结果,这很有用。我还构建了一个`queries.sql`文件,用于使用SQLYac(https://github.com/kalli/sqlyac)和DuckDB检查Parquet文件,以抽查假阴性或假阳性。 最后一步是实际使用管道产生的工件,构建数据的API端点,并在地图上向用户展示兴趣点。其实现与博客文章的主题不太相关,但有趣的是,这一步也是Claude Code想要先编写实现,然后再给我规范进行审批。这是一种许多开发人员都很熟悉的捷径,但也是一个重要的地方,要努力约束AI并让它遵循你最初设定的流程。 ## 你无法为品味编写单元测试 (https://dev.karltryggvason.com/you-cant-unit-test-for-taste/#you-cant-unit-test-for-taste) 从每个路线丰富的候选集合中,我们构建了一个输出工件——一个JSON文件,其中包含该路线的兴趣点。这是第一个实际进行版本控制的兴趣点数据。 也正是在这里,我们意识到我们需要针对不同路线进行微调和使用不同参数。尝试了几条不同的路线后,我很快意识到每条路线的数据都是不同的:在不同领土、国家和大陆的路线有着不同的景点(文化性的vs自然性的vs历史性的等)。这听起来显而易见,但直到这一步我才意识到方差有多大。 例如,我的家乡冰岛的自然风光、历史遗址和居民点混合得很好。但对于其他人口更稠密地区的路线,兴趣点地图基本上变成了一张人口地图,显示了沿途的每个城镇、村庄和小村落。其他兴趣点则集中在城市里,因为建筑、雕像和纪念碑也都在那里。 因此,我们添加了每路线的参数,例如人口过滤、基于Geoname特征类进行排名、将“*主观*”LLM评分相对于维基链接数量的“*客观性*”赋予更高权重。我们还应用了一个地理过滤器,使得在给定半径内只显示最有趣的景点,从而在城市和连接城市的乡村道路之间获得更均匀的兴趣点分布。 总的来说,成功标准的评估是项目中最具挑战性的部分之一。作为一名开发者,我习惯于构建要么有效要么无效的功能,而且通常有客观的方法来衡量功能表现。对于混乱的现实世界数据,很难评估管道的好坏。此外,很容易开始针对特定参数或路线进行优化,后来却发现这项工作导致其他方面严重退化。 验证变得难以推理,因为兴趣点没有基本事实,没有红/绿单元测试来衡量品味。我确信这些是数据科学家熟悉的挑战,并且有相关的框架和评估方法。这将需要更多的迭代和手动覆盖。希望社区能提供反馈和协作。但就目前而言,我已经发布了V1;你可以选择路线在InTheLongRun.app(https://inthelongrun.app/)上尝试一下!

相似文章

AI系统常以测试中不显现的方式失败?

Reddit r/AI_Agents

讨论AI工作流中干净的基准测试环境与混乱的真实世界使用之间的常见差距,导致生产环境失败,并提及评估平台如Confident AI、Braintrust和Langfuse。

找几个人来折腾我的AI测试工具

Reddit r/ArtificialInteligence

作者正在为Behave寻找5-10名beta测试者。Behave是一个AI代理测试/评估工具,它不只是简单检查答案,还能发现幻觉、过早下结论、提供不安全建议以及未能自我纠正等问题。