I considered blurring normals in screen space (or somehow mark hard edges), but as that seemed both expensive (large kernel sizes etc.) and prone to rendering artifacts, I opted for an edge bevel algorithm that operates on the actual geometry. Essentially, it lays out an additional strip of detail around a hard edge to secure its unwelded normal orientation, then smooths the normals of vertices on the original edge:
This properly softens the edges, but it does result in an almost x2 increment of vertices needed. (It's actually more, as lights that render shadow maps can still use the unbeveled geometry since I don't displace any vertices on the bevel).