周六早上十点闹钟响了三遍。你躺在床上刷手机从短视频刷到种草笔记从探店攻略刷到周边游推荐看了几十个选项之后——放下手机叹了口气还是不知道今天去哪儿。这种体验太熟悉了我身边十个朋友里至少有八个这样。不是懒也不是没得选恰恰是选项太多加上预算、人数、爱好、天气、距离这些维度搅在一起大脑直接宕机。后来我花了几个周末做了个小工具名字很直白周末安排生成器。用户只需要输入三个信息——预算、人数、偏好程序自动从活动库里筛出匹配的方案按推荐度排序给出Top3附带预估花费、时长、地点类型和需要准备的物料。做完之后我自己用了很久每逢周末不再陷入翻手机半小时的循环直接看一眼推荐结果就出门了。这篇文章把整个项目的思路、设计取舍、核心代码和踩过的坑都整理出来。如果你想自己写一个类似的小工具或者你想做一个自动化推荐的Web应用但不知道怎么搭建推荐逻辑这篇文章基本可以当参考手册用。不涉及复杂工程不依赖大型框架一个普通开发者甚至懂点Python的业余爱好者都能复现。1. 项目概述把“周末去哪儿”变成一个可计算的问题1.1 选择困难的本质不是选项不够而是决策维度太多先说个有意思的事我观察过周围朋友做周末决策的过程发现他们普遍不是在找一个选项而是在同时权衡多个维度。去哪里、花多少钱、和谁去、吃什么、玩多久、会不会堵车、要不要预约……信息一多人脑的计算能力跟不上结果就是陷入一个死循环看了半天最后一拍大腿——算了还是在家躺着吧。这个现象在心理学里叫决策瘫痪选项越多反而越难选。周末安排生成器的核心思路就是把那些低价值的维度筛选工作交给程序让人只需要表达自己的真实偏好。程序做的事本质上是一次约束求解——把预算、人数、偏好作为约束条件从活动库里筛选出满足条件的候选集再按规则打分排序。说白了这玩意儿并不智能它解决的也不是信息不足的问题而是信息过载导致的选择困难。搞清楚这一点后续的设计才不会跑偏。1.2 工具目标与边界做不会错的方案而不是最优解做一个工具最重要的不是想加多少功能而是想清楚不做哪些功能。我一开始也忍不住想加天气接口、实时路况、商家评分抓取后来全砍了。原因很简单个人项目一旦引入太多实时依赖运维成本会直线上升而且大部分功能对这个场景毫无必要。我给这个项目的定位是三个词快、稳、可落地。快从输入到出结果不超过1秒不做任何耗时计算。稳推荐出来的方案一定是满足预算和人数约束的不会出现超预算几百块的离谱方案。可落地每个推荐方案都附带具体信息——预估花费范围、适合人数、时长、地点类型、需要准备的东西用户不是只拿到一个活动名字而是拿到一张能照着执行的任务卡。边界也很明确它不做实时天气判断不做商家评分聚合不做LBS距离计算。实时天气我留了接口位但用静态数据兜底距离这块我在活动库里直接存了所在区域字段而不是精确经纬度。对周末安排这个场景精度到区域就够了真要精确到从家出发的驾车时间那已经是另一个产品了。2. 方案设计预算、人数、偏好怎么驱动推荐逻辑2.1 预算硬约束必须放在第一层判断预算这个参数是所有约束里优先级最高的。一个人预算50块就不可能推荐人均300的活动这不需要任何智能算法就是一条硬规则。我在设计里把预算分成了四档每一档对应不同的活动池预算档位范围典型活动示例学生档0-80元/人公园散步、免费展览、骑行、公共图书馆小康档80-300元/人手工体验课、密室逃脱、主题咖啡馆、周边徒步品质档300-800元/人温泉、独立影院加餐、周边景区民宿、飞行体验放开档800元/人以上高端日料、跨城主题游、潜水体验、私人影院包场分档的好处有两个第一活动库里的每项活动只需要存一个人均消费区间程序做区间重叠判断即可不需要做复杂的路径规划或费用优化第二用户选档位比填具体数字要轻松得多让用户从五个档位里选一个比让用户输入预算387元要自然得多。这里有一个值得注意的细节活动库里的人均消费区间我用的是一个范围比如某个手工体验馆是80-150元而不是单个数字。因为实际消费往往是一个浮动范围会有材料费、基础套餐和升级项的区别。推荐时判断规则是如果预算档位的上限小于活动的消费下限直接淘汰如果预算档位的上限落在活动的消费区间内价格匹配分数最高如果预算上限远超活动的消费上限给一个较小的超值加分。2.2 人数活动可行性的放大器人数这个参数比大多数人想象的重要得多。很多活动天然就有适配人数的限制密室逃脱一般要4-6人成局双人成行的电影约会两个人刚刚好超过十个人的团建基本只有聚餐和户外拓展能兜底。我把人数也做了分档处理和活动库里的min_people和max_people字段做匹配人数档位范围适配活动逻辑独自一人1人单人友好型如书店、展览、手工体验双人档2人情侣/好友局如电影、咖啡馆、私房菜小团体3-6人大部分桌游、密室、短途徒步、DIY工坊大部队7-12人聚餐、烧烤、团建拓展、包场观影在设计评分逻辑时我用了区间适配而不是精确匹配。比如活动库里的自行车骑行写的是2-8人那么用户选3-6人档时得分最高选双人档或大部队档时也能出结果只是分数会略低。这样避免了一个很尴尬的情况用户填了2人但因为活动库里的骑行标了3-6人结果连推荐都出不来。这个设计本身也是一个很重要的经验推荐系统要尽量少做非黑即白的硬过滤多做软性打分。硬过滤只留给最核心的约束预算上限其他的用分数来调节。2.3 偏好标签体系与匹配策略偏好是最个性化、也最容易做砸的一个维度。我一开始用的是让用户直接输入自然语言描述比如我想去安静的地方然后程序做关键词匹配。效果非常差——安静这种词可以和书店匹配但用户可能心里想的是安静的咖啡馆词不达意的情况特别多。后来我改成标签多选模式。活动库里的每个活动预先打好标签用户勾选自己感兴趣的标签。标签体系设计得不能太粗也不能太细太粗了区分度不够比如只说户外用户分不清是登山还是露营太细了选项太多用户直接看花眼。我最终保留了十几个常用标签保证一屏放得下户外、室内、美食、文艺、休闲、运动、亲子、社交、冒险、手工、展览、自然、动手、躺平匹配策略上我采用了命中计数 类别权重的混合方式。一个活动如果命中多个用户选择的标签得分会非线性增长第一个命中标签加20分第二个加15分第三个加10分边际递减这样可以避免推荐结果被某个标签一票独大。还有一个小细节用户完全不选偏好怎么办这种情况相当常见。我的处理方式是把偏好留空视作随便程序就会完全按预算和人数过滤后的候选集按一个大众推荐分来排序。这个大众推荐分是我在活动库里手写的热度值相当于给每项活动一个基础的默认权重。3. 核心实现活动库、评分模型与代码落地3.1 活动库设计一个JSON文件就够用有朋友建议我用MySQL或SQLite来存活动数据说这样更正规。我犹豫了一下最后还是选了JSON文件。原因特别朴素这个项目的活动数据量根本不会超过几百条JSON文件加载到内存只是几毫秒的事而且我看数据、改数据的时候用任何文本编辑器打开就能改连命令行都不用敲。活动库里每个活动的结构长这样{ id: ACT001, name: 城市公园野餐, category: [户外, 亲子, 休闲], tags: [自然, 轻松, 免费], cost_range: [0, 80], min_people: 1, max_people: 10, duration: 3-4小时, location_type: 城市公园, prep: [野餐垫, 零食, 垃圾袋], heat: 85, notes: 建议下午三点后去阳光不晒 }字段说明一下category是用于和用户偏好标签做匹配的tags是补充描述cost_range是人均消费区间prep是用户出门前需要准备的东西这个字段非常重要推荐结果里附带准备清单用户可以直接照着收拾出门heat是我手动标注的大众热度值0-100用于兜底排序notes是备注信息可以写一些实用提示。这套结构看起来简单但运行一段时间后会发现它其实满足了一个活动推荐系统最核心的需求展示层需要的所有信息都包含了。推荐结果不是推一个名字而是推出一条包含成本、人数、时长、装备清单的完整决策包。3.2 推荐算法一个可解释的评分公式推荐算法的核心是一个评分函数输入是活动对象和用户的三个参数预算档、人数档、偏好标签列表输出是一个分值。程序对候选活动逐个打分然后按分数从高到低排序取前三个作为推荐结果。我用的评分公式是这样的总分 偏好命中分 人数适配分 预算贴合分 热度分 - 上次推荐惩罚分各项权重和规则如下偏好命中分每个命中的用户偏好标签按边际递减规则加分第1个命中加20第2个加15第3个及以后每个加10。人数适配分完全落在活动人数区间内加15分只满足一边比如人数低于下限但差距不大加5分。预算贴合分预算档位上限落在活动cost_range区间内加15分预算上限大于区间上限1.5倍以内加5分超出1.5倍以上不加分。热度分直接将活动的heat值乘以0.1加入总分热度值85的活动就加8.5分保证质量不错但偏好匹配度一般的活动也有机会浮上来。上次推荐惩罚分如果这个活动最近一次被推荐给当前用户总分直接减20分防止连续几次都推同一个结果。这套公式不是一开始就定好的而是我从一个纯规则过滤版本改过来的。最初的版本只做过滤不做排序结果就是符合条件的活动还是很多用户依然要在一堆方案里自己选等于没解决问题。后来我加入评分排序推荐结果的质量就有了明显提升。3.3 接口函数一段可以被任何框架调用的独立逻辑在代码结构上我把推荐逻辑写成一个独立的Python模块不依赖任何Web框架这样不管以后是用Flask做Web页面、用命令行跑还是接入微信机器人都能直接复用。核心函数长这样import json def load_activities(): with open(activities.json, r, encodingutf-8) as f: return json.load(f) def score_activity(activity, budget_level, people_level, preferences): # 硬约束检查预算档上限必须不低于人均消费下限 budget_map {student: 80, economic: 300, quality: 800, luxury: 9999} budget_max budget_map[budget_level] if budget_max activity[cost_range][0]: return None # 直接淘汰 # 人数约束检查完全不匹配则淘汰 people_map {solo: (1,1), couple: (2,2), group: (3,6), crowd: (7,12)} p_min, p_max people_map[people_level] if p_max activity[min_people] or p_min activity[max_people]: return None score 0.0 # 1. 偏好命中 hit_count 0 for pref in preferences: if pref in activity[category]: hit_count 1 if hit_count 1: score 20 elif hit_count 2: score 15 else: score 10 # 2. 人数适配 if p_min activity[min_people] and p_max activity[max_people]: score 15 elif p_max activity[min_people] or p_min activity[max_people]: score 5 # 3. 预算贴合 if budget_max activity[cost_range][0] and budget_max activity[cost_range][1]: score 15 elif budget_max activity[cost_range][1] and budget_max / activity[cost_range][1] 1.5: score 5 # 4. 热度权重 score activity[heat] * 0.1 return score def recommend(budget_level, people_level, preferences, exclude_idsNone): activities load_activities() exclude_ids exclude_ids or [] ranked [] for act in activities: if act[id] in exclude_ids: continue s score_activity(act, budget_level, people_level, preferences) if s is not None: ranked.append((s, act)) ranked.sort(keylambda x: x[0], reverseTrue) return [item[1] for item in ranked[:3]]这个实现的核心思想是先粗筛后精排。粗筛阶段只保留预算和人数都硬性满足条件的活动精排阶段用评分公式决出前三名。粗筛这个环节很关键因为如果放在评分里做惩罚而不是直接淘汰会导致一个超预算500块的活动因为偏好命中多而出线这种结果用户一旦看到就会对整个工具失去信任。3.4 前端交互减法设计的艺术前端我一开始是想做成一个漂亮的大表单预算、人数、偏好、日期、城市全都要填。做到一半发现不对又推倒重来了。重新设计的理念是用户来这个工具是来省时间的不是来填表格的所以交互上必须做减法。最终的前端页面只有三个区域预算选择四个按钮文案分别是勒紧裤腰带80内/人、正常消费300内/人、小资一把800内/人、不差钱。人数选择四个按钮单人、双人、3-6人、7人以上。偏好选择一行可多选的标签按钮。底下就一个大按钮生成方案。样式的设计有个小心机预算和人数用按钮而不是下拉框这样用户不需要先展开再点击少了一步操作偏好用标签按钮点一下选中再点一下取消所见即所得。整个页面没有任何需要键盘输入的地方纯点击操作手机上单手就能完成。4. 实操演示与效果测试4.1 场景一预算80元、双人、偏好选文艺美食这个是我自己最常用的组合测试下来的推荐结果是半天逛旧城区的独立书店和艺术馆预算0元晚上去本地一家开了二十年的老字号面馆吃饭人均30-50元。来拆解一下这个结果的产生逻辑。预算档是勒紧裤腰带档上限80元那么活动库里人均下限超过80的全部淘汰。双人档匹配活动库里大量3-6人的桌游、密室项目因为人数不达标被过滤掉。偏好是文艺美食命中了书店category里的文艺标签得20分命中面馆category里的美食标签再得15分加上预算贴合分15分和热度分自然排到前列。这个推荐结果让我比较满意的点是它不是单推一个书店或者单推一家饭店而是自动组合出了一个半日活动方案。原因是我在活动库里专门设计了几条组合型活动本质上是把一个小型行程表当作一条活动数据来维护。4.2 场景二预算300元、6人、偏好选户外运动模拟一个朋友小团体聚会预算中档人数在小团体档位偏好偏户外和运动。推荐结果是郊野骑行半日游租车人均50-80元含骑行路线推荐和野餐点备选是城市攀岩馆体验人均100-150元和飞盘公园局人均0元只收场地费或免费。这里有一个细节很有趣按照纯打分来看飞盘公园局的得分其实最高——几乎零成本、人数完美匹配、户外和运动标签全命中。但我把骑行排在了第一位原因是热度值不同。我给近郊骑行线路的heat打了90因为实际上这个活动的体验丰富度和出片率都更高我相信大多数第一次用工具的人会对这个结果更满意。这也暴露了这个评分模型的一个局限性热度值是手工维护的多少带点主观色彩。不过对个人工具来说这不是坏事反而是一种人工调校就像咖啡师手调配方一样。4.3 场景三预算800元、4人、偏好选亲子休闲模拟一个带娃家庭的情况。推荐结果为周边亲子主题民宿的日间套餐不含住宿人均200-300元含手工工坊和庭院午餐备选是动物园加亲子餐厅组合、室内儿童科学馆加下午茶。这个场景测试暴露了一个问题当预算拉高到800元档时活动库里符合条件的高消费活动数量明显变少而且有不少活动偏好命中很高但人数档完全不匹配。比如有个豪华温泉套餐标的是2人起订但理论上带娃也能去只是体验一般。我的模型因为人数硬过滤直接把它淘汰了逻辑上没错但实际生活中这种活动其实是可以去的。后来我给这类活动加了一个flexible_people布尔字段标记为true的活动允许人数轻微越界但会扣掉部分分数。这样既不会把不合适的人群硬推给用户也不会因为一个僵硬的人数区间把合理方案扼杀在摇篮里。4.4 测试结论与参数修正跑了二十多组测试场景之后我总结出三个规律第一偏好标签选得越多推荐结果越精准但如果超过五个边际收益就开始下降太贪心的用户反而会因为标签之间互相矛盾而得到平庸的结果。第二预算越高候选池越浅。低价活动天生数量多高价活动的数量呈断崖式下跌。所以我在活动库里特意多补了一些品质档的活动不能让高预算用户每次都只看到同一个结果。第三人数越多推荐难度越大。3人以下的活动选择极其丰富但7人以上的活动本质上是另一种物种——聚餐、团建、户外拓展。后来我把大部队档位单独设计了一套推荐逻辑优先推荐本身容纳人数就大的活动而不做社交适配的精细调整。5. 常见问题与优化手段5.1 推荐结果总是同一个怎么办这是我收到最多的反馈也是我自己最早遇到的问题。原因是如果用户每次都选同一组参数而活动库里有一个在各项指标上全面领先的活动那么它每次都会拿第一。推荐结果稳定不是坏事但对用户来说连续三次看到同一个推荐就变成了机械感。解决办法我在评分公式里已经提了一嘴就是上次推荐惩罚分。我在程序里维护了一个简单的session变量记录最近一次推荐给当前用户的Top3活动ID在下一次推荐时对这些ID各扣20分。这个惩罚力度是经过测试的扣少了没用第一名的优势太明显扣多了也不行会把一个真正优质的活动直接压到候选池底部。另外我还加了一个限定随机因子总分计算完之后有10%的概率让第二名和第一名交换位置。这个随机因子让结果多了一点惊喜感又不至于让推荐结果显得离谱。5.2 一个极端情况预算80元、12个人、偏好全选这是我测试时故意设置的测试用例目的是找出兜底逻辑的漏洞。80元预算加12人基本排除了市面上绝大多数付费活动偏好全选等于没有偏好过滤。结果就是候选池里只剩郊野公园、免费步道、公共球场这几个活动排名出来后三个全是户外散步类看起来单调但它确实就是约束条件下的最优解。这里需要处理的不是推荐结果不好而是用户预期落差。我的解决方案是在前端加了一行提示文案当前条件下可选活动较少建议适当提高预算或减少人数。这行文案极其简单但有效用户看到之后至少明白不是程序出bug了。5.3 活动库内容从哪来怎么持续扩充活动库不是一蹴而就的我的做法是第一版手动录入30条本地的高质量活动跑通流程之后每周根据自己真实出去玩的经验往里加3-5条再之后会邀请朋友使用让朋友投票推荐过且去过的活动热度值往上调推荐过但去了很失望的活动热度值往下调。这个数据扩充路径虽然没有自动抓取但对个人工具来说其实更靠谱。每个活动都是我或朋友亲自去过的质量有保障。自动抓取数据会带来一个非常头疼的问题数据的真实性和时效性不可控商家可能已经倒闭了活动可能已经停了。人工维护虽然慢但每一笔数据都信得过。我遇到过一个问题就是有些活动的价格调整了导致按旧数据推荐的结果严重失真。处理方式是加了一个最后核验时间字段每次手动核验数据时更新这个时间。这样我至少能知道哪些数据是三个月前的需要重点复查。6. 个人经验与潜在扩展方向6.1 做完这个小工具我最大的收获做周末安排生成器之前我以为最大的挑战是写推荐算法。做完之后发现真正的难点在于一件看起来特简单的事把周末去哪儿这个模糊问题拆解成程序能处理的规则。拆解的过程中我犯过很多错。最早我想做一个智能系统让用户输入一句自然语言程序自动理解并给出方案。思考了几天之后放弃了——自然语言理解做不好做一个填空式的规则系统反而能解决90%的真实需求。朴素的方法往往比花哨的招数更管用。在评分公式的调参上我也有一个很重要的体会不要追求一步到位。第一版随便设一组权重跑通流程后再根据一次一次的测试结果去微调。每次只调一个参数记录变化让公式自己说话。我在调试过程中还养成了一个习惯每个评分结果都会打印出各项分值的明细方便我快速定位是哪个环节把结果带偏了。6.2 后续可以接着做的方向这工具本来是可以继续扩展的只是我的时间有限只实现了最核心的部分。如果接着往下做我列几个方向都是实际遇到的需求做成完整行程表生成器。现在推荐出来的是一个活动名称加一些信息更理想的形态是生成一份按时间线排列的完整行程几点出门、先去哪、大概玩多久、几点吃饭、去哪吃、几点回家。接入实时天气。我现在有一个静态的天气备注字段比如雨备选室内手工坊但没有真正接天气API。接上之后就能做到今天下雨自动把户外骑行的分数往下压把室内活动往上顶。加入去过标记和评分反馈。用户可以在推荐卡片上点去过或不喜欢这个反馈直接掉热度值和权重。我不做复杂的学习模型就用简单的统计加权。对个人工具来说比协同过滤这类算法实用得多。把活动库做成可导入导出的格式。这样其他朋友也能用这个工具往自己的JSON文件里填本地活动整个工具就从一个私有项目变成一个小型开源工具。按我自己的经验这个项目的价值不在于推荐算法多高级而在于把人的决策负担转移给程序同时让程序的结果保持足够的可信度。如果你也经常在周末陷入去哪玩的纠结不妨自己也动手写一个——不需要多厉害的编程水平从三十条活动数据开始先把流程跑通再慢慢调优会比我这个版本更好用。