1. 自我介绍

您好,我叫江岸伟,是安徽大学计算机技术专业的硕士研究生,预计2027年毕业,主要积累在C++客户端开发、性能问题定位和AI Agent工程方面。

在腾讯搜狗输入法PC客户端实习期间,我参与资源监控、UI卡顿检测和流畅性优化,定位过跨进程互斥锁无限等待导致输入法及宿主卡死的问题,并通过超时锁接口和TLS配置快照优化热路径,使按键路径加锁次数下降50%。此前在科大讯飞,我做过车位管理、泊车状态流程和基于GJK的通道占用判定。

我还基于LangGraph做了《杀戮尖塔2》自主决策Agent,通过战斗、卡组构建和路线规划三个专家协作,并用MCP接入状态和操作接口,实现从主菜单到终局的无人干预运行。投递游戏客户端,是希望把C++、性能优化和几何算法方面的基础用到实时交互场景,同时继续补齐引擎与游戏开发知识。

  1. 为什么想投递游戏方向呢

建议回答(动机需按本人实际情况调整):我想做游戏客户端,不只是因为对游戏感兴趣,更因为它把交互体验、实时性能和复杂状态管理结合得很紧密。一个功能不仅要逻辑正确,还要考虑帧时间、资源和内存开销,这和我做输入法时对响应延迟、主线程阻塞的关注是相通的。

我的现有经历也有可迁移的部分:输入法实习积累了C++、线程通信与性能定位;汽车项目涉及状态机和碰撞检测;游戏Agent让我实际处理了游戏状态、动作执行和长期策略之间的关系。

当然,做过Agent并不等于做过完整游戏客户端,我不会把引擎、渲染或大型游戏项目经验说成已经具备。希望先从自己更有基础的逻辑、工具和性能方向切入,在实际项目里补足引擎架构、资源管理和运行时系统。

  1. 怎么想到做的杀戮尖塔2agent,有什么收获

建议回答(立项动机需按实际情况核对):我选择这个项目,是因为《杀戮尖塔2》同时有战斗中的短期决策和构筑、路线选择中的长期规划,很适合观察Agent能否在连续状态变化中稳定执行,而不只是对一道题给出看起来合理的答案。

我做了三个专家Agent的场景路由和信息共享,并通过MCP接入实时状态与动作接口。执行时逐动作提交,等待状态结算,再重新定位卡牌和选择目标;这能处理抽牌、弃牌等会改变界面的操作,避免沿用过期位置。

主要收获是把“模型建议”和“系统可靠执行”分开:模型负责决策,程序负责合法性约束、状态校验和流程控制;长期策略则通过按场景加载的手册、分层记忆和战斗复盘维持一致性。评估也应该区分策略好不好、动作能不能正确执行,而不能只看是否跑通一次。

  1. 这个mcp是官方的还是你自己写的

待确认:简历只说明“通过MCP接入实时状态与操作接口”,没有说明MCP服务来自游戏官方、社区开源,还是本人编写,因此不能直接回答“官方的”或“我从零写的”。需要核对服务仓库、作者和实际修改范围。

可以确认的是,我负责的项目侧工作包括接入状态与操作能力、逐动作执行、状态结算校验和语义卡牌定位。这里还要区分MCP协议本身、暴露游戏工具的MCP服务,以及调用这些工具的Agent客户端,使用开放协议不等于服务由游戏官方提供。

口述时按真实情况补全:“底层服务来自【来源】,我复用了【接口】,自己实现或改造了【模块】。”如果是社区服务,就清楚说明在现有工具层上做了哪些可靠性封装;如果确实自研,再讲游戏状态如何取得、动作如何注入,以及错误与版本兼容处理。

  1. 这里为什么要用多agent

因为这三个场景的目标、需要的上下文和决策时间尺度不同。战斗更关注当前回合和生存,卡组构建关注整局牌组方向,路线规划关注收益与风险。拆成专家后,可以分别加载对应策略,减少无关上下文,并更容易定位哪一类决策出了问题。

我并不是让多个Agent同时争抢游戏操作,而是采用确定性场景路由,把当前决策交给对应专家,再通过类型化消息总线共享卡组规划、战斗复盘和路线风险信息。多Agent不等于必须并行调用,也不一定需要不同底层模型。

它的代价是更多消息和状态管理、上下文同步以及可能增加的调用成本。我不会说多Agent天然比单Agent聪明;如果单Agent加按场景切换的提示词已经足够稳定,就应考虑更简单的设计。效果需要在相同模型、预算和测试场景下比较,不能虚构胜率提升。

  1. 为什么不用开源的一些agent来做呢

其实不是排斥开源,我本身就使用了LangGraph来编排流程。需要区分通用框架和可以直接适配某个游戏的完整Agent:框架能解决状态流转和工具编排,但不自动具备这个游戏的状态理解、卡牌语义和动作结算规则。

我的主要工作是游戏侧的特定能力,包括确定性场景路由、专家之间的信息共享、逐动作执行和状态结算校验,以及按场景加载的策略手册。直接接上一个通用Agent,即便能调工具,也不代表它能正确处理抽牌后索引变化、选择界面切换或长期构筑一致性。

建议回答(选型过程需按实际情况核对):优先评估已有方案的接口适配度、可观察性、许可证和维护成本;能复用的部分就复用,只开发确实缺少的领域逻辑。没有实际对比过具体项目,就不要编造一串“试过但不行”的开源框架。

  1. 记忆你是怎么处理的

项目中能确认的做法是:将构筑、选牌、删牌和路线攻略整理成结构化策略手册,按场景加载,并结合分层记忆和战斗复盘调整整局策略,减少无条件拿牌和频繁改变构筑方向。策略手册属于较稳定的知识,不等同于把全部历史对话都塞进上下文。

建议按三层解释设计(具体字段和存储实现待核对):当前场景层保留眼前状态与待执行动作;本局层保留构筑目标、关键牌和已知风险;跨局知识层保存可复用的策略和复盘经验。决策时只读取当前场景所需部分,并明确区分游戏实时事实与模型总结。

记忆不能覆盖最新游戏状态,过期的血量、卡牌位置等应由实时接口纠正;复盘经验也应记录来源和适用条件,避免把一次失败归纳成永远适用的规则。检索方式、压缩策略、数据库和是否跨局持久化,简历未写明,需按实际代码补充,不能默认已用向量数据库或RAG。

  1. 知道最近比较新的jev 模型吗,你觉得他来做你这个游戏的模型怎么样

待确认:“jev”目前无法唯一对应到具体模型,可能是记录时的缩写或拼写,需要先确认完整名称、论文或产品链接。不能直接认定它就是JEPA,也不能声称自己实际评测过这个不确定的模型。

不依赖名称的话,我会先看它的输入输出是否匹配任务:能否理解当前结构化游戏状态、是否支持可靠工具调用、能否遵守合法动作集合,以及长流程中的延迟、成本和稳定性。再在相同场景和预算下比较非法动作率、完成率、胜率与调用成本,区分“会预测状态”和“会选择好动作”。

如果面试官实际指的是JEPA一类预测表征方法,可以说明它可能用于学习状态表征或环境动态,但不能仅凭预测能力就直接替换工具调用型决策Agent。用于游戏规划仍需具体的动作条件、候选动作搜索或策略学习、价值评估及执行接口;是否适合这个项目,要看具体模型能力和可用训练数据。

  1. 为什么用 PostMessage 不用 sendmessage

因为这里要测的是UI消息循环能不能及时响应,同时监控线程不能被UI拖住。PostMessage把消息异步投递到目标窗口所属线程的消息队列后就返回,不等待处理;UI真正处理探测消息时更新ACK,Watchdog就能观察响应情况。简历中的探测周期是200毫秒。

如果直接用跨线程SendMessage,调用方通常要等待UI处理;UI一旦卡住,监控线程也可能阻塞,失去继续观察的能力。SendMessageTimeout可以限制等待,但会引入同步等待和重入等复杂性。PostMessage返回成功也只代表投递成功,不代表UI已经处理,更不等于界面渲染一定流畅。

建议回答(方案层面,需按实际实现核对):探针处理应尽量只更新时间戳,使用单调时钟,并限制未完成探针数量,必要时增加序号,避免卡顿期间堆积的旧消息干扰判断。同时区分投递失败、窗口销毁、系统休眠和监控线程自身被延迟调度,减少误报。

  1. UI 卡顿检测整个时序讲一下

已实现的核心时序是:Watchdog线程每200毫秒向UI线程投递PostMessage探测消息;UI消息循环处理到该消息后,更新原子ACK时间戳;Watchdog持续观察响应是否及时,根据延迟进行三级判定,卡顿时暂停UI线程并采集调用栈,用于定位问题。

时间线可以理解为t0投递、t1消息被UI处理并更新ACK、t2监控线程观察到ACK变化。要注意:如果实现只保存“最近一次ACK时刻”,测得的主要是心跳新鲜度,并不天然等于某次探针的往返时延。若要精确关联t0与t1,需要保存探针序号及发送时间;是否这样实现、三级阈值是多少,都要回看代码,200毫秒只是探测周期。

建议完善的异常链路(非已实现事实):限制未完成探针数量,触发采集后做限频,在最短暂停窗口内保存必要上下文并恢复UI,再异步解析和上报;恢复后结束本次事件。时钟使用单调时钟,并处理投递失败、UI退出、系统休眠以及监控线程自身调度延迟,避免把这些情况直接归因为UI业务卡顿。

  1. 文件残留这里详细讲一下

这部分从资源占用监控切入。我搭建的链路覆盖五个核心进程,采集CPU、内存、磁盘空间和磁盘I/O等指标;基于采集数据定位到升级流程中的旧版IME文件残留问题。修复包括增加临时文件重试清理、调整版本保护逻辑,单设备历史版本数P99从4降到3。

排查时应把“哪些版本应当保留”和“符合删除条件但没有删掉”分开:版本保护是安全策略,不能为了节省空间误删当前使用或回滚需要的文件;删除失败还应区分文件占用、权限、路径和流程中断等原因。这些是需要检查的类别,不能未经证据就断言本次一定是某一种系统原因。

重试清理建议采用明确的可清理名单、有限次数和退避,并在执行前重新校验保护状态,记录失败原因;实际重试时机、保护版本数量和清理触发点需要核对代码。P99从4到3表示设备版本数分布尾部改善,不等于每台设备都少一个版本,也不能直接换算成磁盘空间节省25%。

  1. 搜狗输入法是用了windows 的框架是吧

需要先确认您说的是输入法接入框架,还是界面开发框架。输入法要接入Windows的文本输入机制;Windows提供TSF(Text Services Framework),也存在传统IMM/IMM32兼容链路。它们负责输入上下文、文本服务及与宿主应用的协作,不等同于绘制候选窗所用的UI框架。

我的工作里可以确认的是使用了Windows窗口消息机制、跨进程互斥锁,以及UI线程与Watchdog线程协作;但仅凭PostMessage、UserEnv或IME文件名,无法推断整个产品具体使用TSF、IMM的哪些链路,更不能推断一定用了Qt或MFC。

建议口述:“我主要做的是客户端监控和热路径优化,用到了Win32相关能力。输入法适配层和UI层的具体框架,我会以所负责模块的实现为准。”如果确认了实际接入层,再补充文本上下文、焦点变化和候选窗之间的关系,避免把没有负责的全套架构说成自己都实现过。

  1. 暂停ui 线程 是不是会有些风险呢。怎么避免

有风险。UI线程被挂起时可能持有堆锁、加载器锁或业务锁;采集线程如果随后分配内存、解析符号或写日志,也可能等待这些锁,形成死锁。已知方案确实涉及暂停UI线程采栈,但具体防护措施需要核对,不能承诺完全没有风险。

建议回答(方案层面,需按实际实现核对):由独立采集线程异步处理请求,提前准备缓冲区,挂起期间只做最小必要的上下文采集和有界栈内存复制;随后尽快恢复UI,再对保存的数据做栈展开、符号解析和上报,不在暂停窗口执行复杂逻辑。普通线程的异步采集仍可能受进程内锁影响,必要时考虑进程外采集或系统采样设施。

恢复必须覆盖正常和失败路径,只配对撤销本次成功挂起增加的计数,不反复ResumeThread干扰其他挂起者。RAII能减少正常退出或异常展开时漏恢复,但采集线程若卡死在锁上,析构根本不会执行;因此还要限制暂停范围、避免危险调用并验证失败路径,不能靠一个超时参数或RAII宣称解决所有死锁。

  1. 有没有学习过一些商业的游戏引擎

待确认:简历没有记录Unity、Unreal等商业引擎的学习或使用经历,因此不能直接补成“熟练掌握”或“做过某项目”。这题应按真实程度区分:只了解概念、做过教程小Demo,还是实际使用过某些系统。

如果尚无系统实操,可以回答:“我目前更扎实的是C++、线程与性能定位、状态机和碰撞检测,还没有可以展开讲的商业引擎项目。我知道游戏客户端还需要补足主循环、场景与对象管理、资源生命周期、输入和渲染等知识,会优先用一个小Demo把这些环节串起来。”这是建议表述,需要符合本人现状。

如果确实用过引擎,则补充真实版本、Demo目标、自己写的模块和遇到的问题,例如组件生命周期、资源加载、对象池及Profiler定位;不要仅罗列引擎名。后续学习计划可以作为计划讲,但不能当成已经完成的项目经历。

  1. 一个游戏场景,二维的,给定主角和怪的坐标,然后还有主角的朝向,怎么判断怪在主角的前还是后

设主角位置为P,怪物位置为M,主角非零朝向向量为F,先计算从主角指向怪物的向量D=M−P,再计算点积s=F·D=FxDx+FyDy。s>0表示怪物在主角朝向的前半平面,s<0表示在后半平面,s=0表示位于垂直于朝向的分界线上。

这里只判断前后,不需要归一化,也不需要求角度。若主角与怪物重合,方向没有定义,应单独处理;朝向长度为零也属于无效输入。浮点计算时在零附近留合理容差,容差应结合向量尺度设计。

若进一步判断扇形视野,先做距离范围判断,再检查(F·D)/(|F||D|)是否不小于cos(视野半角)。对常见小于180度的完整视野,还需保持正确的前向判断;不要为了省开方直接平方比较却丢掉点积的正负号。

  1. 点积和叉积

点积a·b=Σai bi=|a||b|cosθ,结果是标量,主要反映方向一致程度。非零向量点积为正表示夹角小于90度,为零表示垂直,为负表示大于90度。向量a在单位方向u上的有符号投影长度是a·u。

三维叉积a×b是垂直于两向量所在平面的向量,方向按右手定则,模长为|a||b|sinθ,也就是两向量张成的平行四边形面积。叉积满足a×b=−b×a,不满足交换律;点积则满足交换律。

二维常用标量cross(a,b)=ax by−ay bx,它是把二维向量嵌入三维后叉积的z分量。在x向右、y向上的坐标系里,正值表示从a到b逆时针,负值表示顺时针;屏幕坐标常常y向下,视觉左右方向会相反。点积适合判断前后、投影,叉积适合判断转向、面积和几何相交中的方向关系。

  1. 怎么用叉积判断夹角呢

仅靠叉积不能唯一确定0到180度的夹角。因为|a×b|/(|a||b|)=sinθ,θ和180°−θ的正弦相同;叉积为零时,也可能是同向、反向,或者存在零向量,不能直接断言夹角为零。

二维非零向量求有符号夹角,使用θ=atan2(cross(a,b), a·b),通常返回[−π,π]范围,既利用叉积区分转向,也利用点积区分锐角和钝角。坐标系的y轴方向会影响符号的几何解释,必要时统一坐标约定。

若只要无符号夹角,三维可以使用atan2(|a×b|, a·b),结果位于[0,π]。也可以用acos((a·b)/(|a||b|)),但要处理零向量并把浮点结果夹到[−1,1],防止舍入误差造成无效值。只是判断左右或前后时,不必真的计算角度。

  1. 二维场景,有一些点,然后问圆心在哪里能覆盖最多的点

首先确认圆的半径R是否固定、边界是否计入,以及数据规模。如果半径不受限,有限个点总能被足够大的圆全部覆盖,这题就没有有意义的最优圆心限制。下面按给定R≥0、边界计入来讨论。

较直接的精确方案:枚举两点Pi、Pj。若它们距离d满足0<d≤2R,以两点为圆上边界点的候选圆心为中点±h·单位垂直向量,其中h=√(R²−d²/4)。对每个候选圆心遍历所有点,统计距离平方≤R²(含适当容差)的数量,保留最优圆心。另用每个输入点作为圆心覆盖单点情形;重合点要计重数并避免除以零,R=0单独统计重合点。

这个方案时间O(n³),候选可流式处理。其依据是:存在覆盖至少两个不同位置的最优圆时,可以平移并旋转一个同样覆盖最优点集的圆,直到至少两点落在边界;只有同位置点的情况已单独处理。

规模更大时,对每个锚点Pi,令它落在圆边界上,把圆心表示为Pi+R(cosθ,sinθ)。其他点距离d≤2R时,对θ贡献以方位角为中心、半宽acos(d/(2R))的可行区间;做角度扫描求最大重叠,总体O(n²log n)。需正确处理跨0角区间、同角端点先计进入以及重合点。

  1. Gjk 碰撞检测

GJK用于判断两个凸形状是否相交,核心转化是:A与B相交,当且仅当原点属于它们的Minkowski差A−B={a−b}。不需要显式构造整个差集,只需要支持函数s(d)=supportA(d)−supportB(−d),得到差集沿方向d最远的点。

算法从一个搜索方向开始取支持点,维护一个小单纯形:二维最多是线段或三角形,三维最多是四面体。每轮根据单纯形上离原点最近的点或区域,缩减单纯形并更新朝向原点的搜索方向;若单纯形包含原点,则相交。若新的支持点在该方向上仍无法越过原点,可分离;等号对应接触或退化,要结合是否把接触算碰撞及容差处理。

工程上要处理重复支持点、零方向、共线或共面、浮点误差、进展不足和迭代上限,不能只写一个无限循环。GJK还可扩展求分离距离;已经相交时求穿透深度通常配合EPA等算法,而不是说基础GJK直接给出所有接触信息。

我在科大讯飞的相关工作,是将GJK碰撞检测和横纵向分级缓冲策略结合,判断通道区域障碍物占用并输出可发布车位列表。具体支持点实现、形状表示和退化阈值,需要按项目代码补充。

  1. 凸多边形相交怎么判断,凹多边形呢

二维凸多边形可以用分离轴定理SAT:对两多边形所有边的法向分别作为候选轴,把所有顶点投影得到两个区间。只要存在一条轴使区间不重叠,就不相交;若所有候选轴都重叠,则两个非退化凸多边形相交。若边界接触也算碰撞,只在有严格间隙时判分离,并结合浮点容差。

只判断区间是否重叠时不必归一化法向,但使用统一距离容差或比较穿透深度时需要考虑轴长度。也可以用GJK,适合能高效实现支持函数的凸形状;在形状数量多时,先用AABB、网格或BVH做粗筛,再进入精确检测。

凹多边形不能直接把上述凸SAT结论或单一支持函数GJK照搬,否则可能实际检测的是凸包而误报。可以先做三角剖分或凸分解,再检测凸子形状对;对于无洞简单多边形,也可检查所有边段是否相交,再检查一个多边形的顶点是否落在另一个内部,避免漏掉完全包含但边不相交的情况。

需要进一步明确自交、洞、退化边和边界点的定义。带洞多边形应使用正确的点包含规则或支持孔洞的分解算法,不能将洞区域误当作实体。

  1. 还了解哪些游戏开发的知识 (说了内存池

我比较能展开的是内存管理、对象池、状态机和碰撞检测。内存池主要管理可复用的内存块,对象池进一步管理对象的构造、重置、使用和回收。像子弹、粒子这类生命周期短且数量多的对象,池化可以减少频繁分配释放带来的开销和波动,但不等于所有场景都比系统分配器快。

以固定大小块池为例,可预分配连续存储并用空闲索引栈管理,申请和归还通常O(1),同时改善局部性。代价是预留内存、容量规划和更复杂的生命周期管理;必须处理对齐、构造析构、重复归还、越界访问,以及池耗尽时的策略。

若面试手写子弹对象池,我会先明确单线程、固定容量、只复用对象的假设,并用带代数的句柄校验过期引用。生产环境还要看峰值活跃数量、分配耗时分位数和缓存表现,再决定是否分级扩容、线程本地池或无锁结构;不能为了展示技术直接引入难验证的并发实现。

  1. 手写一个对象池吧,比如fps 游戏 子弹的对象池

下面给出一个可编译的C++17示例:单线程、固定容量、池内对象始终存在,每次取出时重置;空闲索引栈使申请和归还为O(1)。池满时返回空,调用方明确选择丢弃、延迟或扩容策略,不在热路径偷偷分配。

句柄由池实例、槽位索引和generation组成;归还时增加generation,旧句柄和重复归还会被拒绝。generation达到上限则退休该槽位,避免回绕后重新接受陈旧句柄。池对象不允许复制或移动,句柄只在所属池仍存活时使用。

注意get返回的指针只能临时使用,不能跨release继续保存:对象复用后地址没变,旧裸指针可能误操作新子弹。持久引用应保存句柄并每次校验;多线程版本需要额外同步。这个示例用值重置代替placement new,适合简单Bullet;持有复杂资源的对象还需明确重置和析构策略。

#include <array>
#include <cstdlib>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
 
struct Bullet {
    float x = 0, y = 0;
    float vx = 0, vy = 0;
    float life = 0;
};
 
template <std::size_t N>
class BulletPool {
public:
    struct Handle {
        const BulletPool* owner;
        std::size_t index;
        std::uint64_t generation;
    };
 
    BulletPool() {
        for (std::size_t i = 0; i < N; ++i)
            free_[i] = N - 1 - i;
    }
    BulletPool(const BulletPool&) = delete;
    BulletPool& operator=(const BulletPool&) = delete;
    BulletPool(BulletPool&&) = delete;
    BulletPool& operator=(BulletPool&&) = delete;
 
    std::optional<Handle> acquire(Bullet initial) {
        if (freeCount_ == 0) return std::nullopt;
        const auto index = free_[--freeCount_];
        auto& s = slots_[index];
        s.bullet = initial;
        s.active = true;
        return Handle{this, index, s.generation};
    }
 
    Bullet* get(Handle h) {
        if (!valid(h)) return nullptr;
        return &slots_[h.index].bullet;
    }
 
    bool release(Handle h) {
        if (!valid(h)) return false;
        auto& s = slots_[h.index];
        s.active = false;
        s.bullet = Bullet{};
        // Retire this slot rather than wrap the generation counter.
        if (s.generation != std::numeric_limits<std::uint64_t>::max()) {
            ++s.generation;
            free_[freeCount_++] = h.index;
        }
        return true;
    }
 
private:
    struct Slot {
        Bullet bullet{};
        std::uint64_t generation = 1;
        bool active = false;
    };
    bool valid(Handle h) const {
        return h.owner == this && h.index < N &&
               slots_[h.index].active &&
               slots_[h.index].generation == h.generation;
    }
    std::array<Slot, N> slots_{};
    std::array<std::size_t, N> free_{};
    std::size_t freeCount_ = N;
};
 
// Unlike assert, this check also runs when NDEBUG is defined.
void require(bool condition) {
    if (!condition) std::abort();
}
 
int main() {
    BulletPool<2> pool, another;
    auto a = pool.acquire({0, 0, 1, 0, 3});
    auto b = pool.acquire({1, 2, 0, 1, 2});
    require(a && b && !pool.acquire({})); // Pool exhausted.
    require(another.get(*a) == nullptr);  // Wrong pool.
    require(pool.get(*a)->life == 3);
    pool.get(*a)->x += pool.get(*a)->vx * 0.016f;
    require(pool.release(*a));
    require(!pool.release(*a));          // Double release rejected.
    auto c = pool.acquire({5, 6, 7, 8, 1});
    require(c && c->index == a->index);
    require(pool.get(*a) == nullptr);    // Stale generation rejected.
    require(pool.get(*c)->x == 5);       // New state replaces old state.
    auto invalid = *c;
    invalid.index = 2;
    require(pool.get(invalid) == nullptr);
    require(pool.release(*b) && pool.release(*c));
    BulletPool<0> empty;
    require(!empty.acquire({}));
}