春节回老家走到单元楼下迎面走来一个三十多岁的男人我脑子飞速运转——这人是谁爸爸那边的还是妈妈那边的我应该叫哥还是叫叔要不要先掏出手机假装看消息避开正面打招呼这种“大脑当机表情僵硬”的三秒钟就是许多人每年都要经历的社死瞬间。亲戚计算器这个项目就是为了干掉这三秒钟做的。它不是一个简单的“爸爸的爸爸叫什么”查询工具而是一个把中国亲属称谓体系——包括南北地域差异、方言叫法、远近亲疏、辈分算法——完整数据化、产品化的东西。我把它做成了一个Web应用输入任意一段亲属关系立刻给出对应称呼还能按你所在的地区北方官话区、吴语区、粤语区……切换叫法。这篇文章就把我整个拆解、设计、开发过程写出来包括数据结构怎么建模、称谓合成算法怎么写、南北差异怎么优雅落地以及我在测试中遇到的那些让人头秃的边界情况。如果你也想做类似的中文语义类小工具或者单纯对“亲戚称呼是怎么算出来的”感兴趣这篇应该能给你不少能直接上手的思路。1. 为什么亲戚称呼能让人“社死”项目从痛点出发1.1 一个真实场景引发的产品念头老实说我刚冒出来做亲戚计算器的念头时朋友的第一反应是“这玩意儿不是网上已经有一大堆了吗拿现成的用不就行了”但细想就知道市面上的“亲戚计算器”两极分化很严重一类是给小孩玩的顺口溜级别覆盖到堂哥表姐就没了另一类试图穷举所有关系结果查询流程复杂得像在填物流面单——你要选“父亲的哥哥的妻子”每一步下拉框选完结果还不一定符合你老家的叫法。真正到了过年那种需要秒开、秒查、秒懂的场合都差点意思。真正促使我动手的是我一个北方朋友去南方女朋友家过年女朋友妈妈让他叫人他对着一个五十多岁的女性长辈喊了一声“阿姨”结果女朋友当场脸就绿了。因为按当地习惯那是“舅妈”的婆婆也就是“姻婆婆”不管叫阿姨还是叫奶奶都不对得跟着女朋友叫“阿婆”。后来他跟我说那个瞬间他恨不得地上有条缝。这是个非常真实的痛点现代社会家庭结构小型化很多人连自己家的“三舅姥爷”都搞不清更别说应付跨地域、跨方言的复杂称谓了。工具类产品最怕没场景亲戚计算器恰恰相反场景一目了然——过年、婚礼、满月酒、白事全是用人高峰期。1.2 称谓体系的复杂度远比你想象得高中文普通话层面的称谓规则已经比英语复杂得多——英语里一个uncle走天下中文要拆成伯父、叔父、舅父、姑父、姨父每个词还携带“父系还是母系、长还是幼、血亲还是姻亲”的信息。偏偏这些规则还不是正统的“官方逻辑”能覆盖的。比如“堂”和“表”的区分同宗同姓、同一个祖父的后代里男性长辈的子女算“堂”女性长辈的子女算“表”。一个普通人没受过训练的话遇到“我父亲的哥哥的儿子的儿子”十个里有九个要掰手指头算半天。更可怕的是称谓还分当面称呼面称、背后称呼叙称、落款称呼书面称。同一个“母亲的弟弟”当面叫“舅”背后可以说“我舅舅”书面写“舅父大人”。“父亲的哥哥”当面叫“伯”但有些人家里直接叫“大爷”或者“大大”喊错了虽然不至于被打但气氛一定会微妙地凝固一下。1.3 南北差异是“娘度级”的分水岭如果说辈分和直旁系是普通话内部的复杂度那南北差异就是完全另一维度的问题。举个最典型的例子母亲的母亲北方绝大多数地区叫“姥姥”南方大部分地区叫“外婆”但“外婆”在部分北方家庭里又会被理解成“外祖母”的文雅说法而“姥姥”放到南方有些地区会被当成一种戏谑或者爱称。类似的分歧还有“外公/姥爷”“伯母/大妈/大娘/阿娘”甚至“爷爷/阿公/爹爹”在不同省份的含义能完全错位。我专门拉了一张小表列出了同一个关系在不同区域的常见差异这就是项目的起点普通话标准称谓北方常见叫法南方常见叫法备注外祖母姥姥外婆、阿婆粤语区叫“阿婆”或“婆婆”外祖父姥爷外公、阿公闽南语区叫“阿公”伯母大娘、大妈伯娘、阿姆部分北方农村叫“大大”叔叔的妻子婶子、婶婶阿婶、婶母吴语区常叫“婶娘”妻子的母亲丈母娘岳母、外母粤语口语直接“外母”这个差异不是个别词的差异而是整套称谓系统在不同地区的平行演化。所以亲戚计算器如果只做普通话版本顶多是个及格工具要做到“社死救星”必须把地域维度作为一等公民设计进去而不是事后打补丁。2. 亲属关系建模把“叫啥”变成计算机能算的问题2.1 亲属关系不是树是图要写代码计算称谓第一步就是建模。很多初学者会直觉地认为亲属关系是一棵树——我从根节点祖先一路往下或者从自己出发往下衍生。但稍微想一下就发现这是不对的。比如“我父亲的妻子的母亲”如果我父亲和母亲是夫妻那这个人就是我的“外婆/姥姥”。可是在纯树形结构里“父亲的妻子的母亲”需要绕一大圈而且如果不小心把“妻子的母亲”和“父亲的妻子”两个节点组合起来还有可能绕出环来。更极端的例子“我姐姐的母亲”就是我母亲这是一条环路。所以我把亲属关系建模成一张无向图实际上每条边还带上方向语义每个家庭成员是一个节点边是某种亲属关系。这样整张全家福谱系就是一张由“父亲、母亲、儿子、女儿、丈夫、妻子、哥哥、弟弟、姐姐、妹妹”这些基础关系边连起来的图。2.2 层级、代数、性别三个核心维度任何一段亲属关系都可以用三个维度去归位辈分代数相对于“我”是长辈还是晚辈隔了几层。性别对方的性别决定了很多称谓的走向但注意区分“对方的性别”和“中间节点的性别”——这是后面算法最容易踩坑的地方。直系/旁系及父系/母系是否直接生育链上的关系是从父亲那边绕的还是母亲那边绕的。我定义了一个从“我”出发的代数规则父亲、母亲代数1爷爷、奶奶、外公、外婆代数2。儿子、女儿代数-1孙子、孙女代数-2。哥哥、弟弟、姐姐、妹妹代数0同辈。丈夫、妻子代数0。有了代数程序就能快速判断目标亲戚是长辈还是晚辈。这个是称谓合成的底层坐标。2.3 用BFS搜索关系路径有了图之后计算“我的某个亲戚该叫什么”就变成了“从‘我’这个节点出发找到一条通向目标节点的路径然后把这条路径翻译成称谓”。我直接用BFS广度优先搜索从“我”开始层序遍历整张关系图只要走到目标节点就认为找到了一条关系链。interface RelationEdge { from: string; to: string; type: RelationType; // father | mother | husband | wife | son | daughter | elderBrother | youngerBrother | elderSister | youngerSister } function findRelationPath( graph: Mapstring, RelationEdge[], startId: string, targetId: string ): RelationType[] | null { const queue: { id: string; path: RelationType[]; visited: Setstring }[] [ { id: startId, path: [], visited: new Set([startId]) }, ]; while (queue.length 0) { const { id, path, visited } queue.shift()!; if (id targetId) { return path; } for (const edge of graph.get(id) || []) { if (!visited.has(edge.to)) { queue.push({ id: edge.to, path: [...path, edge.type], visited: new Set([...visited, edge.to]), }); } } } return null; }这里有个关键细节每条边是双向的但边的类型在逆向上需要做反向映射。比如从父亲节点出发走回儿子边的类型就是“son”而不是“father”。我用了一个反向边类型表来处理const REVERSE_RELATION: RecordRelationType, RelationType { father: son, mother: daughter, son: father, daughter: mother, husband: wife, wife: husband, elderBrother: youngerBrother, // 反向时“哥哥”变为“弟弟”辈分不变 youngerBrother: elderBrother, elderSister: youngerSister, youngerSister: elderSister, };注意反向映射不是简单地取反就能完事的“哥哥”反过来是“弟弟”因为站在哥哥的视角自己是弟弟的哥哥。这个坑我一开始还写反过。3. 称谓合成的核心算法与边界规则3.1 路径翻译从“关系链”到“口语称呼”BFS找到关系链之后下一步就是翻译。我最初的想法很天真把整条路径直接当key查字典比如[father, elderBrother]映射成“伯父”。这个方案在小规模关系两到三段边里完全可行但一旦路径变长——比如“我父亲的父亲的哥哥的儿子的女儿”——路径组合数量就会爆炸。后来我把翻译拆成两步第一步先把路径压缩成“语义骨架”。连续的同性质边合并比如“父亲的父亲”直接合并为“祖父”“母亲的母亲的母亲”合并为“外曾祖母”。第二步按语义骨架匹配称谓模板。模板不是简单的key-value而是一组规则如果语义骨架以“父亲父亲”开头且后面接“哥哥/弟弟”那称谓就进入“伯/叔”的决策分支如果后面接“儿子/女儿”则进入“堂/表”决策分支。核心的决策函数我用了一个递归结构function compressPath(path: RelationType[]): Segment[] { const segments: Segment[] []; for (const rel of path) { const last segments[segments.length - 1]; if (last isSameAxis(last.type, rel)) { last.level rel father || rel mother ? last.level 1 : last.level - 1; } else { segments.push({ type: normalizeAxis(rel), level: 0 }); } } return segments; }这里的isSameAxis判断两条边是否属于同一“轴”——父亲和母亲都在父辈轴上儿子和女儿都在子辈轴上。合并完的Segment记录了方向轴父/母/子/女/兄/弟/姐/妹和层级深度后续的称谓规则就很清晰了。3.2 堂表规则的确定逻辑“堂”和“表”的判断是整个算法里最容易出错的一环也是市面许多亲戚计算器做不好的重灾区。递归判定逻辑如下目标亲戚如果是我的同辈先看路径里最后一次“跨越辈分层”是从哪条分支上去的。如果是父亲的兄弟的后代那就是堂亲如果经过父亲的姐妹、母亲的兄弟或母亲的姐妹那就是表亲。举个例子路径[father, elderBrother, son]从父亲到父亲的兄弟再下来到儿子。父亲的兄弟就是伯父/叔父伯父/叔父的儿子就是堂兄弟。路径[father, elderSister, son]父亲的姐妹是姑妈姑妈的儿子是表兄弟。路径[mother, elderBrother, daughter]母亲的兄弟是舅舅舅舅的女儿是表姐妹。这个判断逻辑我在代码里单独抽了一个函数叫isTangOrBiao专门处理同辈平级关系。核心代码大致是function getTangOrBiao(path: RelationType[], targetGender: male | female): string { const axis getBranchAxis(path); if (axis paternal-male) { return targetGender male ? 堂兄弟 : 堂姐妹; } return targetGender male ? 表兄弟 : 表姐妹; }getBranchAxis的原理是找到路径中第一次出现“父亲的兄弟”或“父亲的姐妹”的那个节点父系男性分支就是堂其余就是表。3.3 为什么不用穷举字典表你可能会问既然规则这么多为什么不直接建一张巨大的关系表把所有组合都列出来我试过。最初原型阶段我参考过一些传统老黄历上附带的“亲戚称呼表”那种表一般是两列的左边写“关系”右边写“称谓”。问题在于关系表的row数想要覆盖完整至少要上万条而且一旦加入南北地域差异每条关系还要挂多个称谓数据量直奔十万级。更麻烦的是这类表的覆盖再全也总会出现“我见过但表里没有”的关系组合——比如“我父亲的父亲的姐姐的儿子的女儿”这种长链关系在真实家庭的复杂重组里真会发生穷举法是接不住的。用规则引擎图搜索则完全不怕关系链再长只要不超过BFS的深度上限就能算出来。这也是我最终选择自己写算法的原因——不是一个“死表”而是一套能推理的“活系统”。4. 南北差异落地的技术方案地域称谓包4.1 称谓包的数据结构设计地域差异如果直接写死在规则代码里项目很快就变成一堆if-else了。我采用的做法是把“称谓”和“规则”解耦做成一个可以插拔的“称谓包”dialect pack。每个地域包是一个独立的JSON文件里面含两部分overrides对标准称谓的替换或别名映射。specialRules该地区特有的合成规则比如某些南方方言里“姑姑”和“阿姨”会混用。示例节选吴语区称谓包{ region: wu, name: 吴语区, overrides: { 外祖母: 外婆, 外祖父: 外公, 伯母: 伯娘, 婶母: 婶娘, 姑母: 姑妈, 姨母: 姨娘 }, specialRules: [ { pattern: 父亲_哥哥_妻子, dialect: 阿姆 } ] }核心引擎完全不知道“吴语区”是什么它只负责算出一个标准称谓然后用当前激活的称谓包做一次“方言化”映射。这样普通话版本和带口音的版本都保得住新增一个地区的成本也低。4.2 跨区域语境切换与跟随称呼产品设计上光有称谓包还不够因为用户的实际场景往往是“一个北方人去了南方家庭”或者“一个南方人嫁到了北方”。这种跨区域场景里该怎么叫不完全取决于用户自己的籍贯还要取决于当前家庭语境。我在产品里做了一个“语境切换”开关用户可以设定当前的称呼语境是“爸爸那边”还是“妈妈那边”甚至可以细化到某个具体家庭。语境不同同一个目标亲戚的名称会跟着变。比如妈妈的妈妈在“爸爸那边”的家庭里你跟着爸爸那边的习惯叫“姥姥”但如果语境切到“妈妈那边”你就跟着妈妈家的习惯叫“外婆”。这个设计本质上就是把“叫法”当作一个上下文变量而不是一个静态结果。产品上线后这个是用户反馈里好评度最高的功能之一——很多人查到的不是一个干巴巴的标准称谓而是“我现在这个场合最该叫的那个词”。4.3 方言词条的冲突消解做地域称谓包时最绕不开的问题是同一个词在不同地区可能指向不同的亲戚。比如“大大”这个词在北方许多地区指“伯母”但在南方部分地区可能指“姐姐”在另一些地区甚至指“父亲”。如果只是简单地在多个称谓包里都放一个“大大”用户在切换语境时就会出乱子——同一个词一会儿指向伯母一会儿指向姐姐。我的处理办法是给每个词条加了“词义作用域”scope字段声明它只在某个范围内有效interface DialectTerm { term: string; region: string; meaning: string; // 标准称谓 scope: direct | address | in-law | generic; }查询时先按region过滤再按scope过滤。如果同一个方言词条在不同scope下指向不同称谓系统会优先取当前语境匹配的那个并在界面上提示“该词在不同语境下有歧义”。这虽然是个很细的细节但恰恰是“救星”和“普通工具”之间的分水岭。4.4 冷门称谓的兜底策略哪怕做了地域包也不可能覆盖所有地区的每一个冷门叫法。比如“舅奶奶”母亲的舅舅的妻子、“姑姥姥”父亲的姑母这类相对少见的称谓很多人一辈子也用不上但一旦用到查询不到就会很尴尬。我加了一个兜底策略当标准称谓在当前地域包中没有对应方言词条时自动使用普通话标准称谓并附一个“说明”当前地区暂无该称谓的差异化记载。同时在页面上提供“用户提交”按钮让当地人可以把他们那里的真实叫法提交上来。这个策略刚开始纯粹是为了补数据后来却变成了一个很活跃的UGC入口——很多用户反馈“你们这里写得不对我们这儿应该叫啥”反而帮我积累了不少地方语料。数据驱动型的工具数据来源不能只靠开发者的案头调研用户本身就是最好的数据源。5. “社死救星”的产品落地从工具到场景5.1 查询只是第一步场景化才是救命如果产品只做一个输入框查询按钮那它确实只是“计算器”谈不上“救星”。真正的“救星”要能在你还没开口之前就把你该叫的那个词直接怼到你面前。我设计了几个场景化功能关系树视图用户录入了自己的家庭关系图后可以随时打开一张全景图图上每一个节点都标注“你该叫什么”。会面模式选定一个即将发生的场景比如“去女朋友家过年”系统根据用户录入的“她家都有谁”生成一份当天可能遇到的人名单每人旁边挂着称呼。不用现想直接照读。通讯录标注把手机通讯录里的亲戚联系人跟关系节点绑定。以后来电时直接显示“三舅妈来电”再也不用犹豫。这三个功能的共同点就是把“临时算”变成“提前准备”。过年走亲戚之前花十分钟把关系图谱捋清楚到了现场自然不慌。工具的目的不是让你在寒暄现场低头查手机而是让你开口之前心里就有底。5.2 关系树可视化与“以谁为视角”切换关系树视图一开始做得比较复杂想学专业家谱软件那样把每一层都铺开。结果在手机上显示长辈一多整棵树就成了蜘蛛网。后来砍掉一半功能只保留三层以当前用户为基准往上两层、往下两层。每一层用颜色区分父系/母系——父系暖色母系冷色。核心交互只有一个点击任意节点可以切换“以这个人为视角”重新计算整棵树在他眼中的称谓关系。比如你点击“三舅”那么整张关系树的视角就切到三舅那里你会看到“三舅的爸爸”“三舅的姐姐”——同时系统会提示你“这些人在你的视角里分别是外公、大姨”。这个视角切换功能让复杂的家庭关系一下子变得可理解了很多用户拿它来教家里的孩子认亲戚。5.3 称呼急救卡叫错之后的补救“社死”不止发生在“叫不出”的时候更多发生在“叫错”之后。我在产品里加了一个小众但很实用的功能——称呼急救卡。急救卡本质上是一张小抄当用户明确知道自己可能叫错时可以提前打开某个人的急救卡上面写着正确称呼及发音提示某些方言词带拼音或音标同位替代称呼如果方言词拿不准可以用哪些普通话词替代万一叫错后的补救话术比如“哎呀我按我那边的习惯叫顺嘴了应该说阿婆才对”关于“同位替代称呼”我举几个例子一个南方人去北方家庭摸不准叫“姥姥”还是“外婆”急救卡会提示叫“外婆”是普通话里最通用的外祖母称谓只要对方不是特别在意方言基本不会出问题。这种“容错策略”比直接给一个绝对正确答案更符合真实社交需求。6. 开发踩坑实录那些测试用例没覆盖的坑6.1 关系环路导致的死循环我第一次用BFS遍历亲属关系图时天真地以为不会出事。直到我插入一条数据“我的姐姐的妈妈”——姐姐的妈妈是我妈妈这就是一条环我 - 姐姐 - 妈妈 - 我。如果BFS不做任何环路处理程序会在“我”“姐姐”“妈妈”三个节点之间反复横跳直到爆栈或超时。第一版代码里我虽然带了visited集合但只在入队时检查结果还是出现了深层的环路问题——从A节点出发的路径可以绕一圈回到A然后继续往下走。后来我加了两个保险每个node上的访问记录不是全局唯一的而是“每条路径独立记录”——同一条路径不允许回到已经路过的节点。BFS最大深度限制为6。正常人日常要算的亲戚关系很少超过6跳超过6跳还查不出来的基本可以判定为信息录入有误。深度限制看起来粗暴但实际效果很好既防了死循环也让异常查询快速失败不会让用户转菊花转半天。6.2 “绕路路径”比直接关系先被搜索到BFS按层扩展的特性导致一个很隐蔽的问题图中A和B存在直接关系时BFS可能先找到一条更长的“绕路关系”。比如“我的父亲的母亲”——按常识就是“奶奶”但从图结构上看从“我”到“父亲的母亲”可以走我 - 父亲 - 奶奶但也可能因为家庭关系图里录入了“母亲”的节点而出现我 - 母亲 - 外婆 - 外婆的女儿 - 母亲 - ……这种绕回原点的路径根本走不到目标。更实际的情况是目标节点的确可以通过两条路径到达一条是直系血缘路径一条是姻亲路径。如果不做路径优选程序可能给出一句听起来没错但完全不符合日常称呼的结果。比如“我父亲的妻子的儿子”如果走“妻子的儿子”这条旁路算出来是“继子”但实际情况可能那个人就是我哥——因为“父亲的妻子”就是我母亲。解决方案是搜索到目标节点后不立即返回而是把同一层里所有到达目标的路径都收集起来按“短路径优先、直系优先、父系优先”三个权重做排序。算出来的称谓如果有多个候选以权重最高的为准同时界面上也会显示“其他可能叫法”。这个改动让结果质量提升了一大截。6.3 本地存储与多端同步做完Web版之后我开始支持用户录入自家关系图谱这就带来了另一个问题数据放哪。一开始放在localStorage里好处是不用登录、不用服务器但坏处是换个手机就没了。用户辛辛苦苦录了一大堆亲戚结果手机一摔全没了。后来我用IndexedDB做本地结构化存储同时支持导出JSON文件备份。同步这块我刻意没有做账户体系——因为这种工具的使用频率太低了让人为了存一份家谱专门注册账号劝退率会非常高。导出JSON这个功能后来成了很多用户的隐藏惊喜。有人把JSON发给自己爸妈让爸妈在电脑上帮忙补充关系还有人直接拿JSON做二次开发把自己的家庭数据接进智能音箱里生成语音版“过年叫人训练器”。工具型产品能留给用户数据自由往往比强制上云更得人心。6.4 性能优化从几百毫秒到几十毫秒关系图谱数据量大了之后比如录入了上百个人每次查询都要做BFS虽然深度只有6但广度可能很大。早期测试时查询一次要150-300毫秒对于一次按键查询来说可以接受但体感还是有点“钝”。我做了三个层面的优化最短路径缓存同一个“人/关系对”的查询结果在会话内缓存起来。过年期间打开App反复查的就是那么几个亲戚缓存命中率极高。路径压缩对于固定的家庭图谱预先计算并存储“核心节点之间的直接跳数”查询时优先查预计算表查不到再走BFS。渲染优化关系树视图用Canvas绘制不直接用DOM节点硬铺不然上百个节点的树在低端手机上会卡到怀疑人生。优化完查询稳定在30毫秒以内关系树滚动也流畅了。对工具型产品来说性能不是炫技而是“别让用户等”的基本礼貌。最后的经验补充我做这个项目最大的感受不是“算法有多难”而是“中文称谓背后的文化信息量太大了”。同样一个关系往北走三百公里叫法就变了往南走三百公里又变了一个词背后藏着的是整个地域的家庭结构和社交伦理。算法能解决“算得对”但要真正解决“叫得对”必须把地域、语境、人际边界这些非结构化信息一起揉进来。如果你也想动手做类似语义计算工具我给两个建议第一先做通用规则引擎再考虑方言和地域不要一上来就想覆盖所有口音否则你会被数据埋掉第二一定要保留数据导出的口子用户自己录进去的数据是他的资产你替他保管远不如让他自己拿在手里踏实。这个项目后来被很多用户拿去当过年“补课”工具甚至有人在后台留言说靠着关系树和急救卡成功躲过了三场可能发生的尴尬。我想这就是工具型产品最好的归宿——用完就忘但因为它在那些可能要脚趾抠地的瞬间就真的不再发生了。